mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
The UI needs to be able to identify these to the server and vice versa, so they'll need IDs that don't change. Rather than constructing an ID based on the control points names, make them an owned part of the control point. The constructed ID would be fine, but a UUID will make them more suitable for the database, and this was always fairly gross anyway. Some follow up work if anyone is interested: a bunch of the data that's computed in the various properties can now probably be computed *once* and persisted to the FrontLine type.
403 lines
12 KiB
Python
403 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
import math
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple
|
|
|
|
from dcs.mapping import Point
|
|
from dcs.terrain import (
|
|
caucasus,
|
|
marianaislands,
|
|
nevada,
|
|
normandy,
|
|
persiangulf,
|
|
syria,
|
|
thechannel,
|
|
)
|
|
from dcs.terrain.terrain import Terrain
|
|
from shapely import geometry, ops
|
|
|
|
from .frontline import FrontLine
|
|
from .landmap import Landmap, load_landmap, poly_contains
|
|
from .seasonalconditions import SeasonalConditions
|
|
from ..utils import Heading
|
|
|
|
if TYPE_CHECKING:
|
|
from .controlpoint import ControlPoint, MissionTarget
|
|
from .theatergroundobject import TheaterGroundObject
|
|
|
|
|
|
@dataclass
|
|
class ReferencePoint:
|
|
world_coordinates: Point
|
|
image_coordinates: Point
|
|
|
|
|
|
class ConflictTheater:
|
|
terrain: Terrain
|
|
|
|
overview_image: str
|
|
landmap: Optional[Landmap]
|
|
"""
|
|
land_poly = None # type: Polygon
|
|
"""
|
|
daytime_map: Dict[str, Tuple[int, int]]
|
|
|
|
def __init__(self) -> None:
|
|
self.controlpoints: List[ControlPoint] = []
|
|
"""
|
|
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 add_controlpoint(self, point: ControlPoint) -> None:
|
|
self.controlpoints.append(point)
|
|
|
|
def find_ground_objects_by_obj_name(
|
|
self, obj_name: str
|
|
) -> list[TheaterGroundObject]:
|
|
found = []
|
|
for cp in self.controlpoints:
|
|
for g in cp.ground_objects:
|
|
if g.obj_name == obj_name:
|
|
found.append(g)
|
|
return found
|
|
|
|
def is_in_sea(self, point: Point) -> bool:
|
|
if not self.landmap:
|
|
return False
|
|
|
|
if self.is_on_land(point):
|
|
return False
|
|
|
|
for exclusion_zone in self.landmap.exclusion_zones:
|
|
if poly_contains(point.x, point.y, exclusion_zone):
|
|
return False
|
|
|
|
for sea in self.landmap.sea_zones:
|
|
if poly_contains(point.x, point.y, sea):
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_on_land(self, point: Point) -> bool:
|
|
if not self.landmap:
|
|
return True
|
|
|
|
is_point_included = False
|
|
if poly_contains(point.x, point.y, self.landmap.inclusion_zones):
|
|
is_point_included = True
|
|
|
|
if not is_point_included:
|
|
return False
|
|
|
|
for exclusion_zone in self.landmap.exclusion_zones:
|
|
if poly_contains(point.x, point.y, exclusion_zone):
|
|
return False
|
|
|
|
return True
|
|
|
|
def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point:
|
|
"""Returns the nearest point inside a land exclusion zone from point
|
|
`extend_dist` determines how far inside the zone the point should be placed"""
|
|
if self.is_on_land(near):
|
|
return near
|
|
point = geometry.Point(near.x, near.y)
|
|
nearest_points = []
|
|
if not self.landmap:
|
|
raise RuntimeError("Landmap not initialized")
|
|
for inclusion_zone in self.landmap.inclusion_zones:
|
|
nearest_pair = ops.nearest_points(point, inclusion_zone)
|
|
nearest_points.append(nearest_pair[1])
|
|
min_distance = point.distance(nearest_points[0]) # type: geometry.Point
|
|
nearest_point = nearest_points[0] # type: geometry.Point
|
|
for pt in nearest_points[1:]:
|
|
distance = point.distance(pt)
|
|
if distance < min_distance:
|
|
min_distance = distance
|
|
nearest_point = pt
|
|
assert isinstance(nearest_point, geometry.Point)
|
|
point = Point(point.x, point.y, self.terrain)
|
|
nearest_point = Point(nearest_point.x, nearest_point.y, self.terrain)
|
|
new_point = point.point_from_heading(
|
|
point.heading_between_point(nearest_point),
|
|
point.distance_to_point(nearest_point) + extend_dist,
|
|
)
|
|
return new_point
|
|
|
|
def control_points_for(self, player: bool) -> Iterator[ControlPoint]:
|
|
for point in self.controlpoints:
|
|
if point.captured == player:
|
|
yield point
|
|
|
|
def player_points(self) -> List[ControlPoint]:
|
|
return list(self.control_points_for(player=True))
|
|
|
|
def conflicts(self) -> Iterator[FrontLine]:
|
|
for cp in self.player_points():
|
|
yield from cp.front_lines.values()
|
|
|
|
def enemy_points(self) -> List[ControlPoint]:
|
|
return list(self.control_points_for(player=False))
|
|
|
|
def closest_control_point(
|
|
self, point: Point, allow_naval: bool = False
|
|
) -> ControlPoint:
|
|
closest = self.controlpoints[0]
|
|
closest_distance = point.distance_to_point(closest.position)
|
|
for control_point in self.controlpoints[1:]:
|
|
if control_point.is_fleet and not allow_naval:
|
|
continue
|
|
distance = point.distance_to_point(control_point.position)
|
|
if distance < closest_distance:
|
|
closest = control_point
|
|
closest_distance = distance
|
|
return closest
|
|
|
|
def closest_target(self, point: Point) -> MissionTarget:
|
|
closest: MissionTarget = self.controlpoints[0]
|
|
closest_distance = point.distance_to_point(closest.position)
|
|
for control_point in self.controlpoints[1:]:
|
|
distance = point.distance_to_point(control_point.position)
|
|
if distance < closest_distance:
|
|
closest = control_point
|
|
closest_distance = distance
|
|
for tgo in control_point.ground_objects:
|
|
distance = point.distance_to_point(tgo.position)
|
|
if distance < closest_distance:
|
|
closest = tgo
|
|
closest_distance = distance
|
|
for conflict in self.conflicts():
|
|
distance = conflict.position.distance_to_point(point)
|
|
if distance < closest_distance:
|
|
closest = conflict
|
|
closest_distance = distance
|
|
return closest
|
|
|
|
def closest_opposing_control_points(self) -> Tuple[ControlPoint, ControlPoint]:
|
|
"""
|
|
Returns a tuple of the two nearest opposing ControlPoints in theater.
|
|
(player_cp, enemy_cp)
|
|
"""
|
|
seen = set()
|
|
min_distance = math.inf
|
|
closest_blue = None
|
|
closest_red = None
|
|
for blue_cp in self.player_points():
|
|
for red_cp in self.enemy_points():
|
|
if (blue_cp, red_cp) in seen:
|
|
continue
|
|
seen.add((blue_cp, red_cp))
|
|
seen.add((red_cp, blue_cp))
|
|
|
|
dist = red_cp.position.distance_to_point(blue_cp.position)
|
|
if dist < min_distance:
|
|
closest_red = red_cp
|
|
closest_blue = blue_cp
|
|
min_distance = dist
|
|
|
|
assert closest_blue is not None
|
|
assert closest_red is not None
|
|
return closest_blue, closest_red
|
|
|
|
def find_control_point_by_id(self, id: int) -> ControlPoint:
|
|
for i in self.controlpoints:
|
|
if i.id == id:
|
|
return i
|
|
raise KeyError(f"Cannot find ControlPoint with ID {id}")
|
|
|
|
def control_point_named(self, name: str) -> ControlPoint:
|
|
for cp in self.controlpoints:
|
|
if cp.name == name:
|
|
return cp
|
|
raise KeyError(f"Cannot find ControlPoint named {name}")
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
raise NotImplementedError
|
|
|
|
def heading_to_conflict_from(self, position: Point) -> Optional[Heading]:
|
|
# Heading for a Group to the enemy.
|
|
# Should be the point between the nearest and the most distant conflict
|
|
conflicts: dict[MissionTarget, float] = {}
|
|
|
|
for conflict in self.conflicts():
|
|
conflicts[conflict] = conflict.position.distance_to_point(position)
|
|
|
|
if len(conflicts) == 0:
|
|
return None
|
|
|
|
sorted_conflicts = [
|
|
k for k, v in sorted(conflicts.items(), key=lambda item: item[1])
|
|
]
|
|
last = len(sorted_conflicts) - 1
|
|
|
|
conflict_center = sorted_conflicts[0].position.midpoint(
|
|
sorted_conflicts[last].position
|
|
)
|
|
|
|
return Heading.from_degrees(position.heading_between_point(conflict_center))
|
|
|
|
|
|
class CaucasusTheater(ConflictTheater):
|
|
terrain = caucasus.Caucasus()
|
|
overview_image = "caumap.gif"
|
|
|
|
landmap = load_landmap(Path("resources/caulandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 9),
|
|
"day": (9, 18),
|
|
"dusk": (18, 20),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=4))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.caucasus import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class PersianGulfTheater(ConflictTheater):
|
|
terrain = persiangulf.PersianGulf()
|
|
overview_image = "persiangulf.gif"
|
|
landmap = load_landmap(Path("resources/gulflandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 8),
|
|
"day": (8, 16),
|
|
"dusk": (16, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=4))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.persiangulf import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class NevadaTheater(ConflictTheater):
|
|
terrain = nevada.Nevada()
|
|
overview_image = "nevada.gif"
|
|
landmap = load_landmap(Path("resources/nevlandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (4, 6),
|
|
"day": (6, 17),
|
|
"dusk": (17, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=-8))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.nevada import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class NormandyTheater(ConflictTheater):
|
|
terrain = normandy.Normandy()
|
|
overview_image = "normandy.gif"
|
|
landmap = load_landmap(Path("resources/normandylandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 8),
|
|
"day": (10, 17),
|
|
"dusk": (17, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=0))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.normandy import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class TheChannelTheater(ConflictTheater):
|
|
terrain = thechannel.TheChannel()
|
|
overview_image = "thechannel.gif"
|
|
landmap = load_landmap(Path("resources/channellandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 8),
|
|
"day": (10, 17),
|
|
"dusk": (17, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=2))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.thechannel import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class SyriaTheater(ConflictTheater):
|
|
terrain = syria.Syria()
|
|
overview_image = "syria.gif"
|
|
landmap = load_landmap(Path("resources/syrialandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 8),
|
|
"day": (8, 16),
|
|
"dusk": (16, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=3))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.syria import CONDITIONS
|
|
|
|
return CONDITIONS
|
|
|
|
|
|
class MarianaIslandsTheater(ConflictTheater):
|
|
terrain = marianaislands.MarianaIslands()
|
|
overview_image = "marianaislands.gif"
|
|
|
|
landmap = load_landmap(Path("resources/marianaislandslandmap.p"))
|
|
daytime_map = {
|
|
"dawn": (6, 8),
|
|
"day": (8, 16),
|
|
"dusk": (16, 18),
|
|
"night": (0, 5),
|
|
}
|
|
|
|
@property
|
|
def timezone(self) -> datetime.timezone:
|
|
return datetime.timezone(datetime.timedelta(hours=10))
|
|
|
|
@property
|
|
def seasonal_conditions(self) -> SeasonalConditions:
|
|
from .seasonalconditions.marianaislands import CONDITIONS
|
|
|
|
return CONDITIONS
|