from __future__ import annotations import logging from dataclasses import dataclass from functools import cached_property from typing import Optional, Tuple from dcs.mapping import Point from shapely.geometry import LineString, Point as ShapelyPoint from game.settings import Settings from game.theater.conflicttheater import ConflictTheater, FrontLine from game.theater.controlpoint import ControlPoint from game.utils import Heading @dataclass(frozen=True) class FrontLineBounds: left_position: Point right_position: Point @cached_property def length(self) -> int: return int(self.left_position.distance_to_point(self.right_position)) @cached_property def center(self) -> Point: return (self.left_position + self.right_position) / 2 @cached_property def heading_from_left_to_right(self) -> Heading: return Heading( int(self.left_position.heading_between_point(self.right_position)) ) class FrontLineConflictDescription: def __init__( self, theater: ConflictTheater, front_line: FrontLine, position: Point, heading: Optional[Heading] = None, size: Optional[int] = None, ): self.front_line = front_line self.theater = theater self.position = position self.heading = heading self.size = size @property def blue_cp(self) -> ControlPoint: return self.front_line.blue_cp @property def red_cp(self) -> ControlPoint: return self.front_line.red_cp @classmethod def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater, settings: Settings ) -> Tuple[Point, Heading]: attack_heading = frontline.blue_forward_heading position = cls.find_ground_position( frontline.position, settings.max_frontline_length * 1000, attack_heading.right, theater, ) if position is None: raise RuntimeError("Could not find front line position") return position, attack_heading.opposite @classmethod def frontline_bounds( cls, front_line: FrontLine, theater: ConflictTheater, settings: Settings ) -> FrontLineBounds: """ Returns a vector for a valid frontline location avoiding exclusion zones. """ center_position, heading = cls.frontline_position(front_line, theater, settings) left_heading = heading.left right_heading = heading.right left_position = cls.extend_ground_position( center_position, int(settings.max_frontline_length * 1000 / 2), left_heading, theater, ) right_position = cls.extend_ground_position( center_position, int(settings.max_frontline_length * 1000 / 2), right_heading, theater, ) return FrontLineBounds(left_position, right_position) @classmethod def frontline_cas_conflict( cls, front_line: FrontLine, theater: ConflictTheater, settings: Settings ) -> FrontLineConflictDescription: # TODO: Break apart the front-line and air conflict descriptions. # We're wastefully not caching the front-line bounds here because air conflicts # can't compute bounds, only a position. bounds = cls.frontline_bounds(front_line, theater, settings) conflict = cls( theater=theater, front_line=front_line, position=bounds.left_position, heading=bounds.heading_from_left_to_right, size=bounds.length, ) return conflict @classmethod def extend_ground_position( cls, initial: Point, max_distance: int, heading: Heading, theater: ConflictTheater, ) -> Point: """Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance""" extended = initial.point_from_heading(heading.degrees, max_distance) if theater.landmap is None: # TODO: Why is this possible? return extended p0 = ShapelyPoint(initial.x, initial.y) p1 = ShapelyPoint(extended.x, extended.y) line = LineString([p0, p1]) intersection = line.intersection(theater.landmap.inclusion_zone_only.boundary) if intersection.is_empty: # Max extent does not intersect with the boundary of the inclusion # zone, so the full front line is usable. This does assume that the # front line was centered on a valid location. return extended # Otherwise extend the front line only up to the intersection. return initial.point_from_heading(heading.degrees, p0.distance(intersection)) @classmethod def find_ground_position( cls, initial: Point, max_distance: int, heading: Heading, theater: ConflictTheater, coerce: bool = True, ) -> Optional[Point]: """ Finds the nearest valid ground position along a provided heading and it's inverse up to max_distance. `coerce=True` will return the closest land position to `initial` regardless of heading or distance `coerce=False` will return None if a point isn't found """ pos = initial if theater.is_on_land(pos): return pos for distance in range(0, int(max_distance), 100): pos = initial.point_from_heading(heading.degrees, distance) if theater.is_on_land(pos): return pos pos = initial.point_from_heading(heading.opposite.degrees, distance) if theater.is_on_land(pos): return pos if coerce: pos = theater.nearest_land_pos(initial) return pos logging.error("Didn't find ground position ({})!".format(initial)) return None