From d9d68cd37c18f589b65904120402e4d7e54cb1a7 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 28 Apr 2021 16:13:21 -0700 Subject: [PATCH] Add a new Leaflet based map UI. This is extremely WIP. It is not usable for play yet. Enable with `--new-map`. --- changelog.md | 1 + game/theater/conflicttheater.py | 61 ++-- qt_ui/main.py | 10 +- qt_ui/widgets/map/QLiberationMap.py | 81 ++++- qt_ui/widgets/map/mapmodel.py | 323 ++++++++++++++++++ qt_ui/windows/GameUpdateSignal.py | 4 + qt_ui/windows/QLiberationWindow.py | 19 +- .../waypoints/QFlightWaypointInfoBox.py | 36 +- resources/ui/map/canvas.html | 35 ++ resources/ui/map/map.js | 220 ++++++++++++ 10 files changed, 715 insertions(+), 75 deletions(-) create mode 100644 qt_ui/widgets/map/mapmodel.py create mode 100644 resources/ui/map/canvas.html create mode 100644 resources/ui/map/map.js diff --git a/changelog.md b/changelog.md index af6ca83c..c8d4200f 100644 --- a/changelog.md +++ b/changelog.md @@ -8,6 +8,7 @@ Saves from 2.5 are not compatible with 3.0. * **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them. * **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn. * **[Campaign AI]** Every 30 minutes the AI will plan a CAP, so players can customize their mission better. +* **[UI]** Added (extremely WIP) new web based map UI. Enable with --new-map. * **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present. * **[Modding]** Campaigns now choose locations for factories to spawn. * **[Modding]** Can now install custom factions to /Liberation/Factions instead of the Liberation install directory. diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 9933404e..8a1257ee 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -478,12 +478,36 @@ class ConflictTheater: def __init__(self): self.controlpoints: List[ControlPoint] = [] + self.point_to_ll_transformer = Transformer.from_crs( + self.projection_parameters.to_crs(), CRS("WGS84") + ) + self.ll_to_point_transformer = Transformer.from_crs( + CRS("WGS84"), self.projection_parameters.to_crs() + ) """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: self.land_poly = self.land_poly.difference(geometry.Polygon(x)) """ + def __getstate__(self) -> Dict[str, Any]: + state = self.__dict__.copy() + # Avoid persisting any volatile types that can be deterministically + # recomputed on load for the sake of save compatibility. + del state["point_to_ll_transformer"] + del state["ll_to_point_transformer"] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + self.__dict__.update(state) + # Regenerate any state that was not persisted. + self.point_to_ll_transformer = Transformer.from_crs( + self.projection_parameters.to_crs(), CRS("WGS84") + ) + self.ll_to_point_transformer = Transformer.from_crs( + CRS("WGS84"), self.projection_parameters.to_crs() + ) + def add_controlpoint(self, point: ControlPoint): self.controlpoints.append(point) @@ -637,35 +661,6 @@ class ConflictTheater: return i raise KeyError(f"Cannot find ControlPoint with ID {id}") - def add_json_cp(self, theater, p: dict) -> ControlPoint: - cp: ControlPoint - if p["type"] == "airbase": - - airbase = theater.terrain.airports[p["id"]] - - if "size" in p.keys(): - size = p["size"] - else: - size = SIZE_REGULAR - - if "importance" in p.keys(): - importance = p["importance"] - else: - importance = IMPORTANCE_MEDIUM - - cp = Airfield(airbase, size, importance) - elif p["type"] == "carrier": - cp = Carrier("carrier", Point(p["x"], p["y"]), p["id"]) - else: - cp = Lha("lha", Point(p["x"], p["y"]), p["id"]) - - if "captured_invert" in p.keys(): - cp.captured_invert = p["captured_invert"] - else: - cp.captured_invert = False - - return cp - @staticmethod def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: theaters = { @@ -694,15 +689,11 @@ class ConflictTheater: raise NotImplementedError def point_to_ll(self, point: Point) -> LatLon: - lat, lon = Transformer.from_crs( - self.projection_parameters.to_crs(), CRS("WGS84") - ).transform(point.x, point.y) + lat, lon = self.point_to_ll_transformer.transform(point.x, point.y) return LatLon(lat, lon) def ll_to_point(self, ll: LatLon) -> Point: - x, y = Transformer.from_crs( - CRS("WGS84"), self.projection_parameters.to_crs() - ).transform(ll.latitude, ll.longitude) + x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude) return Point(x, y) diff --git a/qt_ui/main.py b/qt_ui/main.py index 08755e15..8f5af44c 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -36,7 +36,7 @@ from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( ) -def run_ui(game: Optional[Game] = None) -> None: +def run_ui(game: Optional[Game], new_map: bool) -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) @@ -104,7 +104,7 @@ def run_ui(game: Optional[Game] = None) -> None: GameUpdateSignal() # Start window - window = QLiberationWindow(game) + window = QLiberationWindow(game, new_map) window.showMaximized() splash.finish(window) qt_execution_code = app.exec_() @@ -132,6 +132,10 @@ def parse_args() -> argparse.Namespace: help="Emits a warning for weapons without date or fallback information.", ) + parser.add_argument( + "--new-map", action="store_true", help="Use the new map. Non functional." + ) + new_game = subparsers.add_parser("new-game") new_game.add_argument( @@ -239,7 +243,7 @@ def main(): args.cheats, ) - run_ui(game) + run_ui(game, args.new_map) if __name__ == "__main__": diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index afd94e78..b7f2020c 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -4,7 +4,15 @@ import datetime import logging import math from functools import singledispatchmethod -from typing import Iterable, Iterator, List, Optional, Sequence, Tuple +from pathlib import Path +from typing import ( + Iterable, + Iterator, + List, + Optional, + Sequence, + Tuple, +) from PySide2 import QtCore, QtWidgets from PySide2.QtCore import QLineF, QPointF, QRectF, Qt @@ -17,6 +25,11 @@ from PySide2.QtGui import ( QPolygonF, QWheelEvent, ) +from PySide2.QtWebChannel import QWebChannel +from PySide2.QtWebEngineWidgets import ( + QWebEnginePage, + QWebEngineView, +) from PySide2.QtWidgets import ( QFrame, QGraphicsItem, @@ -40,7 +53,10 @@ import qt_ui.uiconstants as CONST from game import Game from game.navmesh import NavMesh from game.theater import ControlPoint, Enum -from game.theater.conflicttheater import FrontLine, ReferencePoint +from game.theater.conflicttheater import ( + FrontLine, + ReferencePoint, +) from game.theater.theatergroundobject import ( TheaterGroundObject, ) @@ -69,6 +85,7 @@ from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject from qt_ui.widgets.map.ShippingLaneSegment import ShippingLaneSegment from qt_ui.widgets.map.SupplyRouteSegment import SupplyRouteSegment +from qt_ui.widgets.map.mapmodel import MapModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal MAX_SHIP_DISTANCE = nautical_miles(80) @@ -112,17 +129,65 @@ class QLiberationMapState(Enum): MOVING_UNIT = 1 -class QLiberationMap(QGraphicsView): +class LoggingWebPage(QWebEnginePage): + def javaScriptConsoleMessage( + self, + level: QWebEnginePage.JavaScriptConsoleMessageLevel, + message: str, + line_number: int, + source: str, + ) -> None: + if level == QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: + logging.error(message) + elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: + logging.warning(message) + else: + logging.info(message) + + +class LiberationMap: + def set_game(self, game: Optional[Game]) -> None: + raise NotImplementedError + + +class LeafletMap(QWebEngineView, LiberationMap): + def __init__(self, game_model: GameModel, parent) -> None: + super().__init__(parent) + self.game_model = game_model + self.setMinimumSize(800, 600) + self.map_model = MapModel(game_model) + + self.channel = QWebChannel() + self.channel.registerObject("game", self.map_model) + + self.page = LoggingWebPage(self) + self.page.setWebChannel(self.channel) + self.page.setHtml(Path("resources/ui/map/canvas.html").read_text()) + self.setPage(self.page) + + self.loadFinished.connect(self.load_finished) + + def load_finished(self) -> None: + self.page.runJavaScript(Path("resources/ui/map/map.js").read_text()) + + def set_game(self, game: Optional[Game]) -> None: + if game is None: + self.map_model.clear() + else: + self.map_model.reset() + + +class QLiberationMap(QGraphicsView, LiberationMap): WAYPOINT_SIZE = 4 reference_point_setup_mode = False instance: Optional[QLiberationMap] = None - def __init__(self, game_model: GameModel): - super(QLiberationMap, self).__init__() + def __init__(self, game_model: GameModel) -> None: + super().__init__() QLiberationMap.instance = self self.game_model = game_model - self.game: Optional[Game] = None # Setup by setGame below. + self.game: Optional[Game] = None # Setup by set_game below. self.state = QLiberationMapState.NORMAL self.waypoint_info_font = QFont() @@ -138,7 +203,7 @@ class QLiberationMap(QGraphicsView): self.factor = 1 self.factorized = 1 self.init_scene() - self.setGame(game_model.game) + self.set_game(game_model.game) # Object displayed when unit is selected self.movement_line = QtWidgets.QGraphicsLineItem( @@ -201,7 +266,7 @@ class QLiberationMap(QGraphicsView): self.setFrameShape(QFrame.NoFrame) self.setDragMode(QGraphicsView.ScrollHandDrag) - def setGame(self, game: Optional[Game]): + def set_game(self, game: Optional[Game]): should_recenter = self.game is None self.game = game if self.game is not None: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py new file mode 100644 index 00000000..726b9105 --- /dev/null +++ b/qt_ui/widgets/map/mapmodel.py @@ -0,0 +1,323 @@ +import logging +from typing import List, Optional, Tuple + +from PySide2.QtCore import Property, QObject, Signal, Slot +from dcs import Point + +from game import Game +from game.profiling import logged_duration +from game.theater import ( + ConflictTheater, + ControlPoint, + TheaterGroundObject, +) +from gen.ato import AirTaskingOrder +from gen.flights.flight import Flight, FlightWaypoint +from qt_ui.models import GameModel +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 + +LeafletLatLon = List[float] + + +class ControlPointJs(QObject): + def __init__( + self, + control_point: ControlPoint, + game_model: GameModel, + theater: ConflictTheater, + ) -> None: + super().__init__() + self.control_point = control_point + self.game_model = game_model + self.theater = theater + + @Property(str) + def name(self) -> str: + return self.control_point.name + + @Property(bool) + def blue(self) -> bool: + return self.control_point.captured + + @Property(list) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.control_point.position) + return [ll.latitude, ll.longitude] + + @Slot() + def open_base_menu(self) -> None: + self.base_details_dialog = QBaseMenu2(None, self.control_point, self.game_model) + self.base_details_dialog.show() + + +class GroundObjectJs(QObject): + def __init__(self, tgo: TheaterGroundObject, theater: ConflictTheater) -> None: + super().__init__() + self.tgo = tgo + self.theater = theater + + @Property(bool) + def blue(self) -> bool: + return self.tgo.control_point.captured + + @Property(list) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.tgo.position) + return [ll.latitude, ll.longitude] + + @Property(list) + def samThreatRanges(self) -> List[float]: + if not self.tgo.might_have_aa: + return [] + + ranges = [] + for group in self.tgo.groups: + threat_range = self.tgo.threat_range(group) + if threat_range: + ranges.append(threat_range.meters) + return ranges + + @Property(list) + def samDetectionRanges(self) -> List[float]: + if not self.tgo.might_have_aa: + return [] + + ranges = [] + for group in self.tgo.groups: + detection_range = self.tgo.detection_range(group) + if detection_range: + ranges.append(detection_range.meters) + return ranges + + +class SupplyRouteJs(QObject): + def __init__(self, points: List[LeafletLatLon]) -> None: + super().__init__() + self._points = points + + @Property(list) + def points(self) -> List[LeafletLatLon]: + return self._points + + +class WaypointJs(QObject): + def __init__(self, waypoint: FlightWaypoint, theater: ConflictTheater) -> None: + super().__init__() + self.waypoint = waypoint + self.theater = theater + + @Property(list) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.waypoint.position) + return [ll.latitude, ll.longitude] + + +class FlightJs(QObject): + flightPlanChanged = Signal() + + def __init__( + self, flight: Flight, selected: bool, theater: ConflictTheater + ) -> None: + super().__init__() + self.flight = flight + self._selected = selected + self.theater = theater + self._waypoints = [] + self.reset_waypoints() + + def reset_waypoints(self) -> None: + self._waypoints = [WaypointJs(p, self.theater) for p in self.flight.points] + self.flightPlanChanged.emit() + + @Property(list, notify=flightPlanChanged) + def flightPlan(self) -> List[WaypointJs]: + return self._waypoints + + @Property(bool) + def blue(self) -> bool: + return self.flight.departure.captured + + @Property(bool) + def selected(self) -> bool: + return self._selected + + +class MapModel(QObject): + cleared = Signal() + + mapCenterChanged = Signal(list) + controlPointsChanged = Signal() + groundObjectsChanged = Signal() + supplyRoutesChanged = Signal() + flightsChanged = Signal() + + def __init__(self, game_model: GameModel) -> None: + super().__init__() + self.game_model = game_model + self._map_center = [0, 0] + self._control_points = [] + self._ground_objects = [] + self._supply_routes = [] + self._flights = [] + self._selected_flight_index: Optional[Tuple[int, int]] = None + GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) + GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) + GameUpdateSignal.get_instance().package_selection_changed.connect( + self.set_package_selection + ) + GameUpdateSignal.get_instance().flight_selection_changed.connect( + self.set_flight_selection + ) + self.reset() + + def set_package_selection(self, index: int) -> None: + # Optional[int] isn't a valid type for a Qt signal. None will be converted to + # zero automatically. We use -1 to indicate no selection. + if index == -1: + self._selected_flight_index = None + else: + self._selected_flight_index = index, 0 + self.reset_atos() + + def set_flight_selection(self, index: int) -> None: + if self._selected_flight_index is None: + if index != -1: + # We don't know what order update_package_selection and + # update_flight_selection will be called in when the last + # package is removed. If no flight is selected, it's not a + # problem to also have no package selected. + logging.error("Flight was selected with no package selected") + return + + # Optional[int] isn't a valid type for a Qt signal. None will be converted to + # zero automatically. We use -1 to indicate no selection. + if index == -1: + self._selected_flight_index = self._selected_flight_index[0], None + self._selected_flight_index = self._selected_flight_index[0], index + self.reset_atos() + + @staticmethod + def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon: + ll = theater.point_to_ll(point) + return [ll.latitude, ll.longitude] + + def reset(self) -> None: + if self.game_model.game is None: + self.clear() + return + with logged_duration("Map reset"): + self.reset_control_points() + self.reset_ground_objects() + self.reset_routes() + self.reset_atos() + + def on_game_load(self, game: Optional[Game]) -> None: + if game is not None: + self.reset_map_center(game.theater) + + def reset_map_center(self, theater: ConflictTheater) -> None: + ll = theater.point_to_ll(theater.terrain.map_view_default.position) + self._map_center = [ll.latitude, ll.longitude] + self.mapCenterChanged.emit(self._map_center) + + @Property(list, notify=mapCenterChanged) + def mapCenter(self) -> LeafletLatLon: + return self._map_center + + def _flights_in_ato(self, ato: AirTaskingOrder, blue: bool) -> List[FlightJs]: + flights = [] + for p_idx, package in enumerate(ato.packages): + for f_idx, flight in enumerate(package.flights): + flights.append( + FlightJs( + flight, + selected=blue and (p_idx, f_idx) == self._selected_flight_index, + theater=self.game.theater, + ) + ) + return flights + + def reset_atos(self) -> None: + self._flights = self._flights_in_ato( + self.game.blue_ato, blue=True + ) + self._flights_in_ato(self.game.red_ato, blue=False) + self.flightsChanged.emit() + + @Property(list, notify=flightsChanged) + def flights(self) -> List[FlightJs]: + return self._flights + + def reset_control_points(self) -> None: + self._control_points = [ + ControlPointJs(c, self.game_model, self.game.theater) + for c in self.game.theater.controlpoints + ] + self.controlPointsChanged.emit() + + @Property(list, notify=controlPointsChanged) + def controlPoints(self) -> List[ControlPointJs]: + return self._control_points + + def reset_ground_objects(self) -> None: + seen = set() + self._ground_objects = [] + for cp in self.game.theater.controlpoints: + for tgo in cp.ground_objects: + if tgo.name in seen: + continue + seen.add(tgo.name) + + self._ground_objects.append(GroundObjectJs(tgo, self.game.theater)) + self.groundObjectsChanged.emit() + + @Property(list, notify=groundObjectsChanged) + def groundObjects(self) -> List[GroundObjectJs]: + return self._ground_objects + + def reset_routes(self) -> None: + seen = set() + self._supply_routes = [] + for control_point in self.game.theater.controlpoints: + seen.add(control_point) + for destination, convoy_route in control_point.convoy_routes.items(): + if destination in seen: + continue + self._supply_routes.append( + SupplyRouteJs( + [ + self.leaflet_coord_for(p, self.game.theater) + for p in convoy_route + ] + ) + ) + for destination, shipping_lane in control_point.shipping_lanes.items(): + if destination in seen: + continue + if control_point.is_friendly(destination.captured): + self._supply_routes.append( + SupplyRouteJs( + [ + self.leaflet_coord_for(p, self.game.theater) + for p in shipping_lane + ] + ) + ) + self.supplyRoutesChanged.emit() + + @Property(list, notify=supplyRoutesChanged) + def supplyRoutes(self) -> List[SupplyRouteJs]: + return self._supply_routes + + def clear(self) -> None: + self._control_points = [] + self._supply_routes = [] + self._ground_objects = [] + self._flights = [] + self.cleared.emit() + + @property + def game(self) -> Game: + if self.game_model.game is None: + raise RuntimeError("No game loaded") + return self.game_model.game diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index f70c0e49..6f2119e6 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -15,6 +15,8 @@ class GameUpdateSignal(QObject): budgetupdated = Signal(Game) debriefingReceived = Signal(Debriefing) + game_loaded = Signal(Game) + flight_paths_changed = Signal() package_selection_changed = Signal(int) # -1 indicates no selection. flight_selection_changed = Signal(int) # -1 indicates no selection. @@ -23,6 +25,8 @@ class GameUpdateSignal(QObject): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self + self.game_loaded.connect(self.updateGame) + def select_package(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences self.package_selection_changed.emit(-1 if index is None else index) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 76d683ab..bfad8870 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -27,7 +27,7 @@ from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel from qt_ui.widgets.ato import QAirTaskingOrderPanel -from qt_ui.widgets.map.QLiberationMap import QLiberationMap +from qt_ui.widgets.map.QLiberationMap import LeafletMap, QLiberationMap, LiberationMap from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QDebriefingWindow import QDebriefingWindow from qt_ui.windows.infos.QInfoPanel import QInfoPanel @@ -38,7 +38,7 @@ from qt_ui.windows.preferences.QLiberationPreferencesWindow import ( class QLiberationWindow(QMainWindow): - def __init__(self, game: Optional[Game]) -> None: + def __init__(self, game: Optional[Game], new_map: bool) -> None: super(QLiberationWindow, self).__init__() self.game = game @@ -46,7 +46,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) - self.liberation_map = QLiberationMap(self.game_model) + self.liberation_map: LiberationMap = self.create_map(new_map) self.setGeometry(300, 100, 270, 100) self.setWindowTitle(f"DCS Liberation - v{VERSION}") @@ -254,14 +254,13 @@ class QLiberationWindow(QMainWindow): ) if file is not None: game = persistency.load_game(file[0]) - GameUpdateSignal.get_instance().updateGame(game) + GameUpdateSignal.get_instance().game_loaded.emit(game) def saveGame(self): logging.info("Saving game") if self.game.savepath: persistency.save_game(self.game) - GameUpdateSignal.get_instance().updateGame(self.game) liberation_install.setup_last_save_file(self.game.savepath) liberation_install.save_config() else: @@ -283,7 +282,12 @@ class QLiberationWindow(QMainWindow): def onGameGenerated(self, game: Game): logging.info("On Game generated") self.game = game - GameUpdateSignal.get_instance().updateGame(self.game) + GameUpdateSignal.get_instance().game_loaded.emit(self.game) + + def create_map(self, new_map: bool) -> LiberationMap: + if new_map: + return LeafletMap(self.game_model, self) + return QLiberationMap(self.game_model) def setGame(self, game: Optional[Game]): try: @@ -291,8 +295,7 @@ class QLiberationWindow(QMainWindow): if self.info_panel is not None: self.info_panel.setGame(game) self.game_model.set(self.game) - if self.liberation_map is not None: - self.liberation_map.setGame(game) + self.liberation_map.set_game(game) except AttributeError: logging.exception("Incompatible save game") QMessageBox.critical( diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py index 29834c01..9bcadd6d 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py @@ -1,22 +1,19 @@ -from PySide2.QtWidgets import QGroupBox, QGridLayout, QLabel, QHBoxLayout, QVBoxLayout +from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QVBoxLayout from gen.flights.flight import FlightWaypoint class QFlightWaypointInfoBox(QGroupBox): - def __init__(self, flight_wpt: FlightWaypoint = None): + def __init__(self) -> None: super(QFlightWaypointInfoBox, self).__init__("Waypoint") - self.flight_wpt = flight_wpt - if flight_wpt is None: - self.flight_wpt = FlightWaypoint(0, 0, 0) - self.x_position_label = QLabel(str(self.flight_wpt.x)) - self.y_position_label = QLabel(str(self.flight_wpt.y)) - self.alt_label = QLabel(str(int(self.flight_wpt.alt.feet))) - self.name_label = QLabel(str(self.flight_wpt.name)) - self.desc_label = QLabel(str(self.flight_wpt.description)) + self.x_position_label = QLabel("0") + self.y_position_label = QLabel("0") + self.alt_label = QLabel("0") + self.name_label = QLabel("") + self.desc_label = QLabel("") self.init_ui() - def init_ui(self): + def init_ui(self) -> None: layout = QVBoxLayout() @@ -53,13 +50,10 @@ class QFlightWaypointInfoBox(QGroupBox): self.setLayout(layout) - def set_flight_waypoint(self, flight_wpt: FlightWaypoint): - self.flight_wpt = flight_wpt - if flight_wpt is None: - self.flight_wpt = FlightWaypoint(0, 0, 0) - self.x_position_label.setText(str(self.flight_wpt.x)) - self.y_position_label.setText(str(self.flight_wpt.y)) - self.alt_label.setText(str(int(self.flight_wpt.alt.feet))) - self.name_label.setText(str(self.flight_wpt.name)) - self.desc_label.setText(str(self.flight_wpt.description)) - self.setTitle(self.flight_wpt.name) + def set_flight_waypoint(self, flight_wpt: FlightWaypoint) -> None: + self.x_position_label.setText(str(flight_wpt.x)) + self.y_position_label.setText(str(flight_wpt.y)) + self.alt_label.setText(str(int(flight_wpt.alt.feet))) + self.name_label.setText(str(flight_wpt.name)) + self.desc_label.setText(str(flight_wpt.description)) + self.setTitle(flight_wpt.name) diff --git a/resources/ui/map/canvas.html b/resources/ui/map/canvas.html new file mode 100644 index 00000000..3c035992 --- /dev/null +++ b/resources/ui/map/canvas.html @@ -0,0 +1,35 @@ + + + + DCS Liberation Map + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js new file mode 100644 index 00000000..6cad0155 --- /dev/null +++ b/resources/ui/map/map.js @@ -0,0 +1,220 @@ +/* + * TODO: + * + * - Culling + * - Threat zones + * - Navmeshes + * - CV waypoints + * - Time of day/weather themeing + * - Exclusion zones + * - Commit ranges + * - Waypoint info + * - Supply route status + * - Front line + * - Debug flight plan drawing + * - Icon variety + */ + +const Colors = Object.freeze({ + Blue: "#0084ff", + Red: "#c85050", +}); + +var map = L.map("map").setView([0, 0], 3); + +// https://esri.github.io/esri-leaflet/api-reference/layers/basemap-layer.html +var baseLayers = { + "Imagery Clarity": L.esri.basemapLayer("ImageryClarity", { maxZoom: 17 }), + "Imagery Firefly": L.esri.basemapLayer("ImageryFirefly", { maxZoom: 17 }), +}; + +var defaultBaseMap = baseLayers["Imagery Clarity"]; +defaultBaseMap.addTo(map); + +// Enabled by default, so addTo(map). +var controlPointsLayer = L.layerGroup().addTo(map); +var groundObjectsLayer = L.layerGroup().addTo(map); +var supplyRoutesLayer = L.layerGroup().addTo(map); +var redSamThreatLayer = L.layerGroup().addTo(map); +var blueFlightPlansLayer = L.layerGroup().addTo(map); + +// Added to map by the user via layer controls. +var blueSamThreatLayer = L.layerGroup(); +var blueSamDetectionLayer = L.layerGroup(); +var redSamDetectionLayer = L.layerGroup(); +var redFlightPlansLayer = L.layerGroup(); +var selectedFlightPlansLayer = L.layerGroup(); + +L.control + .groupedLayers( + baseLayers, + { + "Points of Interest": { + "Control points": controlPointsLayer, + "Ground objects": groundObjectsLayer, + "Supply routes": supplyRoutesLayer, + }, + "Air Defenses": { + "Ally SAM threat range": blueSamThreatLayer, + "Enemy SAM threat range": redSamThreatLayer, + "Ally SAM detection range": blueSamDetectionLayer, + "Enemy SAM detection range": redSamDetectionLayer, + }, + "Flight Plans": { + "Hide": L.layerGroup(), + "Show selected blue": selectedFlightPlansLayer, + "Show all blue": blueFlightPlansLayer, + "Show all red": redFlightPlansLayer, + }, + }, + { collapsed: false, exclusiveGroups: ["Flight Plans"] } + ) + .addTo(map); + +var friendlyCpIcon = new L.Icon({ + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +var enemyCpIcon = new L.Icon({ + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +var game; +new QWebChannel(qt.webChannelTransport, function (channel) { + game = channel.objects.game; + drawInitialMap(); + game.cleared.connect(clearAllLayers); + game.mapCenterChanged.connect(recenterMap); + game.controlPointsChanged.connect(drawControlPoints); + game.groundObjectsChanged.connect(drawGroundObjects); + game.supplyRoutesChanged.connect(drawSupplyRoutes); + game.flightsChanged.connect(drawFlightPlans); +}); + +function recenterMap(center) { + map.setView(center, 8, { animate: true, duration: 1 }); +} + +function iconFor(player) { + if (player) { + return friendlyCpIcon; + } else { + return enemyCpIcon; + } +} + +function drawControlPoints() { + controlPointsLayer.clearLayers(); + game.controlPoints.forEach((cp) => { + L.marker(cp.position, { icon: iconFor(cp.blue) }) + .on("click", function () { + cp.open_base_menu(); + }) + .addTo(controlPointsLayer); + }); +} + +function drawSamThreatsAt(tgo) { + var detectionLayer = tgo.blue ? blueSamDetectionLayer : redSamDetectionLayer; + var threatLayer = tgo.blue ? blueSamThreatLayer : redSamThreatLayer; + var threatColor = tgo.blue ? Colors.Blue : Colors.Red; + var detectionColor = tgo.blue ? "#bb89ff" : "#eee17b"; + + tgo.samDetectionRanges.forEach((range) => { + L.circle(tgo.position, { + radius: range, + color: detectionColor, + fill: false, + weight: 2, + }).addTo(detectionLayer); + }); + + tgo.samThreatRanges.forEach((range) => { + L.circle(tgo.position, { + radius: range, + color: threatColor, + fill: false, + weight: 2, + }).addTo(threatLayer); + }); +} + +function drawGroundObjects() { + groundObjectsLayer.clearLayers(); + blueSamDetectionLayer.clearLayers(); + redSamDetectionLayer.clearLayers(); + blueSamThreatLayer.clearLayers(); + redSamThreatLayer.clearLayers(); + game.groundObjects.forEach((tgo) => { + L.marker(tgo.position, { icon: iconFor(tgo.blue) }).addTo( + groundObjectsLayer + ); + drawSamThreatsAt(tgo); + }); +} + +function drawSupplyRoutes() { + supplyRoutesLayer.clearLayers(); + game.supplyRoutes.forEach((route) => { + L.polyline(route.points).addTo(supplyRoutesLayer); + }); +} + +function drawFlightPlan(flight) { + var layer = flight.blue ? blueFlightPlansLayer : redFlightPlansLayer; + var color = flight.blue ? Colors.Blue : Colors.Red; + var highlight = "#ffff00"; + var points = []; + flight.flightPlan.forEach((waypoint) => { + points.push(waypoint.position); + L.circle(waypoint.position, { radius: 50, color: color }).addTo(layer); + if (flight.selected) { + L.circle(waypoint.position, { radius: 50, color: highlight }).addTo( + selectedFlightPlansLayer + ); + } + }); + L.polyline(points, { color: color }).addTo(layer); + if (flight.selected) { + L.polyline(points, { color: highlight }).addTo(selectedFlightPlansLayer); + } +} + +function drawFlightPlans() { + blueFlightPlansLayer.clearLayers(); + redFlightPlansLayer.clearLayers(); + selectedFlightPlansLayer.clearLayers(); + game.flights.forEach((flight) => { + drawFlightPlan(flight); + }); +} + +function drawInitialMap() { + recenterMap(game.mapCenter); + drawControlPoints(); + drawGroundObjects(); + drawSupplyRoutes(); + drawFlightPlans(); +} + +function clearAllLayers() { + map.eachLayer(function (layer) { + if (layer.clearLayers !== undefined) { + layer.clearLayers(); + } + }); +}