diff --git a/client/src/api/eventstream.tsx b/client/src/api/eventstream.tsx index b5b5a081..47d4c019 100644 --- a/client/src/api/eventstream.tsx +++ b/client/src/api/eventstream.tsx @@ -21,8 +21,10 @@ import { } from "./frontLinesSlice"; import FrontLine from "./frontline"; import reloadGameState from "./gamestate"; +import { liberationApi } from "./liberationApi"; import Tgo from "./tgo"; import { updateTgo } from "./tgosSlice"; +import { threatZonesUpdated } from "./threatZonesSlice"; import { LatLng } from "leaflet"; interface GameUpdateEvents { @@ -68,6 +70,16 @@ export const handleStreamedEvents = ( dispatch(endCombat(id)); } + if (events.threat_zones_updated) { + dispatch(liberationApi.endpoints.getThreatZones.initiate()).then( + (result) => { + if (result.data) { + dispatch(threatZonesUpdated(result.data)); + } + } + ); + } + for (const flight of events.new_flights) { dispatch(registerFlight(flight)); } diff --git a/client/src/api/game.ts b/client/src/api/game.ts index 9aca5749..4377e2a5 100644 --- a/client/src/api/game.ts +++ b/client/src/api/game.ts @@ -1,6 +1,7 @@ import { ControlPoint } from "./controlpoint"; import { Flight } from "./flight"; import FrontLine from "./frontline"; +import { ThreatZoneContainer } from "./liberationApi"; import SupplyRoute from "./supplyroute"; import Tgo from "./tgo"; import { LatLngLiteral } from "leaflet"; @@ -11,5 +12,6 @@ export default interface Game { supply_routes: SupplyRoute[]; front_lines: FrontLine[]; flights: Flight[]; + threat_zones: ThreatZoneContainer; map_center: LatLngLiteral | null; } diff --git a/client/src/api/threatZonesSlice.ts b/client/src/api/threatZonesSlice.ts new file mode 100644 index 00000000..902fef1d --- /dev/null +++ b/client/src/api/threatZonesSlice.ts @@ -0,0 +1,49 @@ +import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; +import { ThreatZoneContainer } from "./liberationApi"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +interface ThreatZonesState { + zones: ThreatZoneContainer; +} + +const initialState: ThreatZonesState = { + zones: { + blue: { + full: [], + aircraft: [], + air_defenses: [], + radar_sams: [], + }, + red: { + full: [], + aircraft: [], + air_defenses: [], + radar_sams: [], + }, + }, +}; + +export const threatZonesSlice = createSlice({ + name: "threatZonesState", + initialState, + reducers: { + updated: (state, action: PayloadAction) => { + state.zones = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(gameLoaded, (state, action) => { + state.zones = action.payload.threat_zones; + }); + builder.addCase(gameUnloaded, (state) => { + state.zones = initialState.zones; + }); + }, +}); + +export const { updated: threatZonesUpdated } = threatZonesSlice.actions; + +export const selectThreatZones = (state: RootState) => state.threatZones; + +export default threatZonesSlice.reducer; diff --git a/client/src/app/store.ts b/client/src/app/store.ts index 0d3d0239..4877849f 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -6,6 +6,7 @@ import frontLinesReducer from "../api/frontLinesSlice"; import mapReducer from "../api/mapSlice"; import supplyRoutesReducer from "../api/supplyRoutesSlice"; import tgosReducer from "../api/tgosSlice"; +import threatZonesReducer from "../api/threatZonesSlice"; import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit"; export const store = configureStore({ @@ -17,6 +18,7 @@ export const store = configureStore({ map: mapReducer, supplyRoutes: supplyRoutesReducer, tgos: tgosReducer, + threatZones: threatZonesReducer, [baseApi.reducerPath]: baseApi.reducer, }, middleware: (getDefaultMiddleware) => diff --git a/client/src/components/liberationmap/LiberationMap.tsx b/client/src/components/liberationmap/LiberationMap.tsx index ac6a61f3..4918ab20 100644 --- a/client/src/components/liberationmap/LiberationMap.tsx +++ b/client/src/components/liberationmap/LiberationMap.tsx @@ -8,6 +8,11 @@ import FlightPlansLayer from "../flightplanslayer"; import FrontLinesLayer from "../frontlineslayer"; import SupplyRoutesLayer from "../supplyrouteslayer"; import TgosLayer from "../tgoslayer/TgosLayer"; +import { CoalitionThreatZones } from "../threatzones"; +import { + ThreatZonesLayer, + ThreatZoneFilter, +} from "../threatzones/ThreatZonesLayer"; import "./LiberationMap.css"; import { Map } from "leaflet"; import { useEffect, useRef } from "react"; @@ -83,6 +88,10 @@ export default function LiberationMap() { + + + + ); } diff --git a/client/src/components/threatzones/CoalitionThreatZones.tsx b/client/src/components/threatzones/CoalitionThreatZones.tsx new file mode 100644 index 00000000..bf56a3e2 --- /dev/null +++ b/client/src/components/threatzones/CoalitionThreatZones.tsx @@ -0,0 +1,35 @@ +import { ThreatZoneFilter, ThreatZonesLayer } from "./ThreatZonesLayer"; +import { LayersControl } from "react-leaflet"; + +interface CoalitionThreatZonesProps { + blue: boolean; +} + +export function CoalitionThreatZones(props: CoalitionThreatZonesProps) { + const color = props.blue ? "Blue" : "Red"; + return ( + <> + + + + + + + + + + + + + + ); +} diff --git a/client/src/components/threatzones/ThreatZone.tsx b/client/src/components/threatzones/ThreatZone.tsx new file mode 100644 index 00000000..a55eb555 --- /dev/null +++ b/client/src/components/threatzones/ThreatZone.tsx @@ -0,0 +1,24 @@ +import { LatLng } from "leaflet"; +import { Polygon } from "react-leaflet"; + +interface ThreatZoneProps { + poly: number[][]; + blue: boolean; +} + +export default function ThreatZone(props: ThreatZoneProps) { + const color = props.blue ? "#0084ff" : "#c85050"; + // TODO: Fix response model so the type can be used directly. + const positions = props.poly.map(([lat, lng]) => new LatLng(lat, lng)); + return ( + + ); +} diff --git a/client/src/components/threatzones/ThreatZonesLayer.tsx b/client/src/components/threatzones/ThreatZonesLayer.tsx new file mode 100644 index 00000000..fffa2e93 --- /dev/null +++ b/client/src/components/threatzones/ThreatZonesLayer.tsx @@ -0,0 +1,43 @@ +import { selectThreatZones } from "../../api/threatZonesSlice"; +import { useAppSelector } from "../../app/hooks"; +import ThreatZone from "./ThreatZone"; +import { LayerGroup } from "react-leaflet"; + +export enum ThreatZoneFilter { + FULL, + AIRCRAFT, + AIR_DEFENSES, + RADAR_SAMS, +} + +interface ThreatZonesLayerProps { + blue: boolean; + filter: ThreatZoneFilter; +} + +export function ThreatZonesLayer(props: ThreatZonesLayerProps) { + const allZones = useAppSelector(selectThreatZones).zones; + const zones = props.blue ? allZones.blue : allZones.red; + var filtered; + switch (props.filter) { + case ThreatZoneFilter.FULL: + filtered = zones.full; + break; + case ThreatZoneFilter.AIRCRAFT: + filtered = zones.aircraft; + break; + case ThreatZoneFilter.AIR_DEFENSES: + filtered = zones.air_defenses; + break; + case ThreatZoneFilter.RADAR_SAMS: + filtered = zones.radar_sams; + break; + } + return ( + + {filtered.map((poly, idx) => ( + + ))} + + ); +} diff --git a/client/src/components/threatzones/index.ts b/client/src/components/threatzones/index.ts new file mode 100644 index 00000000..d0e929e4 --- /dev/null +++ b/client/src/components/threatzones/index.ts @@ -0,0 +1,2 @@ +export { ThreatZonesLayer, ThreatZoneFilter } from "./ThreatZonesLayer"; +export { CoalitionThreatZones } from "./CoalitionThreatZones"; diff --git a/game/server/game/models.py b/game/server/game/models.py index e6029d5d..7e933064 100644 --- a/game/server/game/models.py +++ b/game/server/game/models.py @@ -8,6 +8,7 @@ from game.server.controlpoints.models import ControlPointJs from game.server.flights.models import FlightJs from game.server.frontlines.models import FrontLineJs from game.server.leaflet import LeafletPoint +from game.server.mapzones.models import ThreatZoneContainerJs from game.server.supplyroutes.models import SupplyRouteJs from game.server.tgos.models import TgoJs @@ -21,6 +22,7 @@ class GameJs(BaseModel): supply_routes: list[SupplyRouteJs] front_lines: list[FrontLineJs] flights: list[FlightJs] + threat_zones: ThreatZoneContainerJs map_center: LeafletPoint class Config: @@ -34,5 +36,6 @@ class GameJs(BaseModel): supply_routes=SupplyRouteJs.all_in_game(game), front_lines=FrontLineJs.all_in_game(game), flights=FlightJs.all_in_game(game, with_waypoints=True), + threat_zones=ThreatZoneContainerJs.for_game(game), map_center=game.theater.terrain.map_view_default.position.latlng(), ) diff --git a/game/server/mapzones/models.py b/game/server/mapzones/models.py index 329ab6db..9b9530d6 100644 --- a/game/server/mapzones/models.py +++ b/game/server/mapzones/models.py @@ -1,11 +1,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import BaseModel from game.server.leaflet import LeafletPoint, LeafletPoly, ShapelyUtil from game.theater import ConflictTheater from game.threatzones import ThreatZones +if TYPE_CHECKING: + from game import Game + class MapZonesJs(BaseModel): inclusion: list[LeafletPoly] @@ -49,3 +54,14 @@ class ThreatZoneContainerJs(BaseModel): class Config: title = "ThreatZoneContainer" + + @staticmethod + def for_game(game: Game) -> ThreatZoneContainerJs: + return ThreatZoneContainerJs( + blue=ThreatZonesJs.from_zones( + game.threat_zone_for(player=True), game.theater + ), + red=ThreatZonesJs.from_zones( + game.threat_zone_for(player=False), game.theater + ), + ) diff --git a/game/server/mapzones/routes.py b/game/server/mapzones/routes.py index 8b64bc97..1ed805b2 100644 --- a/game/server/mapzones/routes.py +++ b/game/server/mapzones/routes.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from game import Game from game.server import GameContext -from .models import MapZonesJs, ThreatZoneContainerJs, ThreatZonesJs, UnculledZoneJs +from .models import MapZonesJs, ThreatZoneContainerJs, UnculledZoneJs from ..leaflet import ShapelyUtil router: APIRouter = APIRouter(prefix="/map-zones") @@ -41,7 +41,4 @@ def get_unculled_zones( def get_threat_zones( game: Game = Depends(GameContext.require), ) -> ThreatZoneContainerJs: - return ThreatZoneContainerJs( - blue=ThreatZonesJs.from_zones(game.threat_zone_for(player=True), game.theater), - red=ThreatZonesJs.from_zones(game.threat_zone_for(player=False), game.theater), - ) + return ThreatZoneContainerJs.for_game(game)