Add a new Leaflet based map UI.

This is extremely WIP. It is not usable for play yet. Enable with
`--new-map`.
This commit is contained in:
Dan Albert 2021-04-28 16:13:21 -07:00
parent 56abd0bb7f
commit d9d68cd37c
10 changed files with 715 additions and 75 deletions

View File

@ -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 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]** 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. * **[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. * **[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]** Campaigns now choose locations for factories to spawn.
* **[Modding]** Can now install custom factions to <DCS saved games>/Liberation/Factions instead of the Liberation install directory. * **[Modding]** Can now install custom factions to <DCS saved games>/Liberation/Factions instead of the Liberation install directory.

View File

@ -478,12 +478,36 @@ class ConflictTheater:
def __init__(self): def __init__(self):
self.controlpoints: List[ControlPoint] = [] 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]) self.land_poly = geometry.Polygon(self.landmap[0][0])
for x in self.landmap[1]: for x in self.landmap[1]:
self.land_poly = self.land_poly.difference(geometry.Polygon(x)) 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): def add_controlpoint(self, point: ControlPoint):
self.controlpoints.append(point) self.controlpoints.append(point)
@ -637,35 +661,6 @@ class ConflictTheater:
return i return i
raise KeyError(f"Cannot find ControlPoint with ID {id}") 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 @staticmethod
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
theaters = { theaters = {
@ -694,15 +689,11 @@ class ConflictTheater:
raise NotImplementedError raise NotImplementedError
def point_to_ll(self, point: Point) -> LatLon: def point_to_ll(self, point: Point) -> LatLon:
lat, lon = Transformer.from_crs( lat, lon = self.point_to_ll_transformer.transform(point.x, point.y)
self.projection_parameters.to_crs(), CRS("WGS84")
).transform(point.x, point.y)
return LatLon(lat, lon) return LatLon(lat, lon)
def ll_to_point(self, ll: LatLon) -> Point: def ll_to_point(self, ll: LatLon) -> Point:
x, y = Transformer.from_crs( x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude)
CRS("WGS84"), self.projection_parameters.to_crs()
).transform(ll.latitude, ll.longitude)
return Point(x, y) return Point(x, y)

View File

@ -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 os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens
app = QApplication(sys.argv) app = QApplication(sys.argv)
@ -104,7 +104,7 @@ def run_ui(game: Optional[Game] = None) -> None:
GameUpdateSignal() GameUpdateSignal()
# Start window # Start window
window = QLiberationWindow(game) window = QLiberationWindow(game, new_map)
window.showMaximized() window.showMaximized()
splash.finish(window) splash.finish(window)
qt_execution_code = app.exec_() 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.", 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 = subparsers.add_parser("new-game")
new_game.add_argument( new_game.add_argument(
@ -239,7 +243,7 @@ def main():
args.cheats, args.cheats,
) )
run_ui(game) run_ui(game, args.new_map)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -4,7 +4,15 @@ import datetime
import logging import logging
import math import math
from functools import singledispatchmethod 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 import QtCore, QtWidgets
from PySide2.QtCore import QLineF, QPointF, QRectF, Qt from PySide2.QtCore import QLineF, QPointF, QRectF, Qt
@ -17,6 +25,11 @@ from PySide2.QtGui import (
QPolygonF, QPolygonF,
QWheelEvent, QWheelEvent,
) )
from PySide2.QtWebChannel import QWebChannel
from PySide2.QtWebEngineWidgets import (
QWebEnginePage,
QWebEngineView,
)
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame, QFrame,
QGraphicsItem, QGraphicsItem,
@ -40,7 +53,10 @@ import qt_ui.uiconstants as CONST
from game import Game from game import Game
from game.navmesh import NavMesh from game.navmesh import NavMesh
from game.theater import ControlPoint, Enum 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 ( from game.theater.theatergroundobject import (
TheaterGroundObject, 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.QMapGroundObject import QMapGroundObject
from qt_ui.widgets.map.ShippingLaneSegment import ShippingLaneSegment from qt_ui.widgets.map.ShippingLaneSegment import ShippingLaneSegment
from qt_ui.widgets.map.SupplyRouteSegment import SupplyRouteSegment from qt_ui.widgets.map.SupplyRouteSegment import SupplyRouteSegment
from qt_ui.widgets.map.mapmodel import MapModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
MAX_SHIP_DISTANCE = nautical_miles(80) MAX_SHIP_DISTANCE = nautical_miles(80)
@ -112,17 +129,65 @@ class QLiberationMapState(Enum):
MOVING_UNIT = 1 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 WAYPOINT_SIZE = 4
reference_point_setup_mode = False reference_point_setup_mode = False
instance: Optional[QLiberationMap] = None instance: Optional[QLiberationMap] = None
def __init__(self, game_model: GameModel): def __init__(self, game_model: GameModel) -> None:
super(QLiberationMap, self).__init__() super().__init__()
QLiberationMap.instance = self QLiberationMap.instance = self
self.game_model = game_model 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.state = QLiberationMapState.NORMAL
self.waypoint_info_font = QFont() self.waypoint_info_font = QFont()
@ -138,7 +203,7 @@ class QLiberationMap(QGraphicsView):
self.factor = 1 self.factor = 1
self.factorized = 1 self.factorized = 1
self.init_scene() self.init_scene()
self.setGame(game_model.game) self.set_game(game_model.game)
# Object displayed when unit is selected # Object displayed when unit is selected
self.movement_line = QtWidgets.QGraphicsLineItem( self.movement_line = QtWidgets.QGraphicsLineItem(
@ -201,7 +266,7 @@ class QLiberationMap(QGraphicsView):
self.setFrameShape(QFrame.NoFrame) self.setFrameShape(QFrame.NoFrame)
self.setDragMode(QGraphicsView.ScrollHandDrag) self.setDragMode(QGraphicsView.ScrollHandDrag)
def setGame(self, game: Optional[Game]): def set_game(self, game: Optional[Game]):
should_recenter = self.game is None should_recenter = self.game is None
self.game = game self.game = game
if self.game is not None: if self.game is not None:

View File

@ -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

View File

@ -15,6 +15,8 @@ class GameUpdateSignal(QObject):
budgetupdated = Signal(Game) budgetupdated = Signal(Game)
debriefingReceived = Signal(Debriefing) debriefingReceived = Signal(Debriefing)
game_loaded = Signal(Game)
flight_paths_changed = Signal() flight_paths_changed = Signal()
package_selection_changed = Signal(int) # -1 indicates no selection. package_selection_changed = Signal(int) # -1 indicates no selection.
flight_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__() super(GameUpdateSignal, self).__init__()
GameUpdateSignal.instance = self GameUpdateSignal.instance = self
self.game_loaded.connect(self.updateGame)
def select_package(self, index: Optional[int]) -> None: def select_package(self, index: Optional[int]) -> None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
self.package_selection_changed.emit(-1 if index is None else index) self.package_selection_changed.emit(-1 if index is None else index)

View File

@ -27,7 +27,7 @@ from qt_ui.models import GameModel
from qt_ui.uiconstants import URLS from qt_ui.uiconstants import URLS
from qt_ui.widgets.QTopPanel import QTopPanel from qt_ui.widgets.QTopPanel import QTopPanel
from qt_ui.widgets.ato import QAirTaskingOrderPanel 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.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QDebriefingWindow import QDebriefingWindow from qt_ui.windows.QDebriefingWindow import QDebriefingWindow
from qt_ui.windows.infos.QInfoPanel import QInfoPanel from qt_ui.windows.infos.QInfoPanel import QInfoPanel
@ -38,7 +38,7 @@ from qt_ui.windows.preferences.QLiberationPreferencesWindow import (
class QLiberationWindow(QMainWindow): class QLiberationWindow(QMainWindow):
def __init__(self, game: Optional[Game]) -> None: def __init__(self, game: Optional[Game], new_map: bool) -> None:
super(QLiberationWindow, self).__init__() super(QLiberationWindow, self).__init__()
self.game = game self.game = game
@ -46,7 +46,7 @@ class QLiberationWindow(QMainWindow):
Dialog.set_game(self.game_model) Dialog.set_game(self.game_model)
self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model)
self.info_panel = QInfoPanel(self.game) 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.setGeometry(300, 100, 270, 100)
self.setWindowTitle(f"DCS Liberation - v{VERSION}") self.setWindowTitle(f"DCS Liberation - v{VERSION}")
@ -254,14 +254,13 @@ class QLiberationWindow(QMainWindow):
) )
if file is not None: if file is not None:
game = persistency.load_game(file[0]) game = persistency.load_game(file[0])
GameUpdateSignal.get_instance().updateGame(game) GameUpdateSignal.get_instance().game_loaded.emit(game)
def saveGame(self): def saveGame(self):
logging.info("Saving game") logging.info("Saving game")
if self.game.savepath: if self.game.savepath:
persistency.save_game(self.game) persistency.save_game(self.game)
GameUpdateSignal.get_instance().updateGame(self.game)
liberation_install.setup_last_save_file(self.game.savepath) liberation_install.setup_last_save_file(self.game.savepath)
liberation_install.save_config() liberation_install.save_config()
else: else:
@ -283,7 +282,12 @@ class QLiberationWindow(QMainWindow):
def onGameGenerated(self, game: Game): def onGameGenerated(self, game: Game):
logging.info("On Game generated") logging.info("On Game generated")
self.game = game 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]): def setGame(self, game: Optional[Game]):
try: try:
@ -291,8 +295,7 @@ class QLiberationWindow(QMainWindow):
if self.info_panel is not None: if self.info_panel is not None:
self.info_panel.setGame(game) self.info_panel.setGame(game)
self.game_model.set(self.game) self.game_model.set(self.game)
if self.liberation_map is not None: self.liberation_map.set_game(game)
self.liberation_map.setGame(game)
except AttributeError: except AttributeError:
logging.exception("Incompatible save game") logging.exception("Incompatible save game")
QMessageBox.critical( QMessageBox.critical(

View File

@ -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 from gen.flights.flight import FlightWaypoint
class QFlightWaypointInfoBox(QGroupBox): class QFlightWaypointInfoBox(QGroupBox):
def __init__(self, flight_wpt: FlightWaypoint = None): def __init__(self) -> None:
super(QFlightWaypointInfoBox, self).__init__("Waypoint") super(QFlightWaypointInfoBox, self).__init__("Waypoint")
self.flight_wpt = flight_wpt self.x_position_label = QLabel("0")
if flight_wpt is None: self.y_position_label = QLabel("0")
self.flight_wpt = FlightWaypoint(0, 0, 0) self.alt_label = QLabel("0")
self.x_position_label = QLabel(str(self.flight_wpt.x)) self.name_label = QLabel("")
self.y_position_label = QLabel(str(self.flight_wpt.y)) self.desc_label = QLabel("")
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.init_ui() self.init_ui()
def init_ui(self): def init_ui(self) -> None:
layout = QVBoxLayout() layout = QVBoxLayout()
@ -53,13 +50,10 @@ class QFlightWaypointInfoBox(QGroupBox):
self.setLayout(layout) self.setLayout(layout)
def set_flight_waypoint(self, flight_wpt: FlightWaypoint): def set_flight_waypoint(self, flight_wpt: FlightWaypoint) -> None:
self.flight_wpt = flight_wpt self.x_position_label.setText(str(flight_wpt.x))
if flight_wpt is None: self.y_position_label.setText(str(flight_wpt.y))
self.flight_wpt = FlightWaypoint(0, 0, 0) self.alt_label.setText(str(int(flight_wpt.alt.feet)))
self.x_position_label.setText(str(self.flight_wpt.x)) self.name_label.setText(str(flight_wpt.name))
self.y_position_label.setText(str(self.flight_wpt.y)) self.desc_label.setText(str(flight_wpt.description))
self.alt_label.setText(str(int(self.flight_wpt.alt.feet))) self.setTitle(flight_wpt.name)
self.name_label.setText(str(self.flight_wpt.name))
self.desc_label.setText(str(self.flight_wpt.description))
self.setTitle(self.flight_wpt.name)

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<title>DCS Liberation Map</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossorigin=""></script>
<script src="https://unpkg.com/esri-leaflet@3.0.1/dist/esri-leaflet.js"
integrity="sha512-JmpptMCcCg+Rd6x0Dbg6w+mmyzs1M7chHCd9W8HPovnImG2nLAQWn3yltwxXRM7WjKKFFHOAKjjF2SC4CgiFBg=="
crossorigin=""></script>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet-groupedlayercontrol@0.6.1/dist/leaflet.groupedlayercontrol.min.css"
crossorigin="sha384-jTaPj8/7LfdZXicEJ/SUWan4gkbYw6lXyoRUNPMmNV3fYVfBzHJK8wD6A8Djh+3A">
<script
src="https://unpkg.com/leaflet-groupedlayercontrol@0.6.1/dist/leaflet.groupedlayercontrol.min.js"
integrity="sha384-XAr1poM2RCR9/QQFki7ylrGSdmvYE0NuHghuRuxb/k9zJQA53y6qR5te5jJRZlcL"
crossorigin="">
</script>
<style>
body { padding: 0; margin: 0; }
html, body, #map { height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
</body>
</html>

220
resources/ui/map/map.js Normal file
View File

@ -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();
}
});
}