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 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 <DCS saved games>/Liberation/Factions instead of the Liberation install directory.

View File

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

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
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__":

View File

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

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

View File

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

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

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