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

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