Support mobile CPs in the new map.

https://github.com/dcs-liberation/dcs_liberation/issues/2039
This commit is contained in:
Dan Albert 2022-03-05 15:41:58 -08:00
parent 2c6e8c414c
commit 995e28cb32
4 changed files with 336 additions and 13 deletions

View File

@ -6,11 +6,35 @@ import { LatLng } from "leaflet";
export const apiSlice = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: HTTP_URL }),
tagTypes: ["ControlPoint"],
endpoints: (builder) => ({
getCommitBoundaryForFlight: builder.query<LatLng[], string>({
query: (flightId) => `flights/${flightId}/commit-boundary`,
providesTags: ["ControlPoint"],
}),
setControlPointDestination: builder.mutation<
void,
{ id: number; destination: LatLng }
>({
query: ({ id, destination }) => ({
url: `control-points/${id}/destination`,
method: "PUT",
body: { lat: destination.lat, lng: destination.lng },
invalidatesTags: ["ControlPoint"],
}),
}),
controlPointCancelTravel: builder.mutation<void, number>({
query: (id) => ({
url: `control-points/${id}/cancel-travel`,
method: "PUT",
invalidatesTags: ["ControlPoint"],
}),
}),
}),
});
export const { useGetCommitBoundaryForFlightQuery } = apiSlice;
export const {
useGetCommitBoundaryForFlightQuery,
useSetControlPointDestinationMutation,
useControlPointCancelTravelMutation,
} = apiSlice;

View File

@ -1,8 +1,26 @@
import {
useControlPointCancelTravelMutation,
useSetControlPointDestinationMutation,
} from "../../api/api";
import backend from "../../api/backend";
import { ControlPoint as ControlPointModel } from "../../api/controlpoint";
import { Icon, Point } from "leaflet";
import {
Icon,
LatLng,
Point,
Marker as LMarker,
Polyline as LPolyline,
} from "leaflet";
import { Symbol as MilSymbol } from "milsymbol";
import { Marker, Tooltip } from "react-leaflet";
import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import ReactDOMServer from "react-dom/server";
import { Marker, Polyline, Tooltip } from "react-leaflet";
function iconForControlPoint(cp: ControlPointModel) {
const symbol = new MilSymbol(cp.sidc, {
@ -16,11 +34,205 @@ function iconForControlPoint(cp: ControlPointModel) {
});
}
function openInfoDialog(controlPoint: ControlPointModel) {
backend.post(`/qt/info/control-point/${controlPoint.id}`);
}
function openNewPackageDialog(controlPoint: ControlPointModel) {
backend.post(`/qt/create-package/control-point/${controlPoint.id}`);
}
interface ControlPointProps {
controlPoint: ControlPointModel;
}
export default function ControlPoint(props: ControlPointProps) {
function LocationTooltipText(props: ControlPointProps) {
return <h3 style={{ margin: 0 }}>{props.controlPoint.name}</h3>;
}
function metersToNauticalMiles(meters: number) {
return meters * 0.000539957;
}
function formatLatLng(latLng: LatLng) {
const lat = latLng.lat.toFixed(2);
const lng = latLng.lng.toFixed(2);
const ns = latLng.lat >= 0 ? "N" : "S";
const ew = latLng.lng >= 0 ? "E" : "W";
return `${lat}&deg;${ns} ${lng}&deg;${ew}`;
}
function destinationTooltipText(
cp: ControlPointModel,
destinationish: LatLng,
inRange: boolean
) {
const destination = new LatLng(destinationish.lat, destinationish.lng);
const distance = metersToNauticalMiles(
destination.distanceTo(cp.position)
).toFixed(1);
if (!inRange) {
return `Out of range (${distance}nm away)`;
}
const dest = formatLatLng(destination);
return `${cp.name} moving ${distance}nm to ${dest} next turn`;
}
function PrimaryMarker(props: ControlPointProps) {
// We can't use normal state to update the marker tooltip or the line points
// because if we set any state in the drag event it will re-render the
// component and interrupt dragging. Instead, keep refs to the objects and
// mutate them directly.
//
// For the same reason, the path is owned by this component, because updating
// sibling state would be messy. Lifting the state into the parent would still
// cause this component to redraw.
const marker: MutableRefObject<LMarker | undefined> = useRef();
const pathLine: MutableRefObject<LPolyline | undefined> = useRef();
const [hasDestination, setHasDestination] = useState<boolean>(
props.controlPoint.destination != null
);
const [pathDestination, setPathDestination] = useState<LatLng>(
props.controlPoint.destination
? props.controlPoint.destination
: props.controlPoint.position
);
const [position, setPosition] = useState<LatLng>(
props.controlPoint.destination
? props.controlPoint.destination
: props.controlPoint.position
);
const setDestination = useCallback((destination: LatLng) => {
setPathDestination(destination);
setPosition(destination);
setHasDestination(true);
}, []);
const resetDestination = useCallback(() => {
setPathDestination(props.controlPoint.position);
setPosition(props.controlPoint.position);
setHasDestination(false);
}, [props.controlPoint.position]);
const [putDestination, { isLoading }] =
useSetControlPointDestinationMutation();
const [cancelTravel] = useControlPointCancelTravelMutation();
useEffect(() => {
marker.current?.setTooltipContent(
props.controlPoint.destination
? destinationTooltipText(
props.controlPoint,
props.controlPoint.destination,
true
)
: ReactDOMServer.renderToString(
<LocationTooltipText controlPoint={props.controlPoint} />
)
);
});
return (
<>
<Marker
position={position}
icon={iconForControlPoint(props.controlPoint)}
draggable={props.controlPoint.mobile && !isLoading}
autoPan
// We might draw other markers on top of the CP. The tooltips from the
// other markers are helpful so we want to keep them, but make sure the CP
// is always the clickable thing.
zIndexOffset={1000}
opacity={props.controlPoint.destination ? 0.5 : 1}
ref={(ref) => {
if (ref != null) {
marker.current = ref;
}
}}
eventHandlers={{
click: () => {
if (!hasDestination) {
openInfoDialog(props.controlPoint);
}
},
contextmenu: () => {
if (props.controlPoint.destination) {
cancelTravel(props.controlPoint.id).then(() => {
resetDestination();
});
} else {
openNewPackageDialog(props.controlPoint);
}
},
drag: (event) => {
const destination = event.target.getLatLng();
backend
.get(
`/control-points/${props.controlPoint.id}/destination-in-range?lat=${destination.lat}&lng=${destination.lng}`
)
.then((inRange) => {
marker.current?.setTooltipContent(
destinationTooltipText(
props.controlPoint,
destination,
inRange.data
)
);
});
pathLine.current?.setLatLngs([
props.controlPoint.position,
destination,
]);
},
dragend: async (event) => {
const currentPosition = new LatLng(
pathDestination.lat,
pathDestination.lng,
pathDestination.alt
);
const destination = event.target.getLatLng();
setDestination(destination);
try {
await putDestination({
id: props.controlPoint.id,
destination: destination,
}).unwrap();
} catch (error) {
console.error("setDestination failed", error);
setDestination(currentPosition);
}
},
}}
>
<Tooltip />
</Marker>
<Polyline
positions={[props.controlPoint.position, pathDestination]}
weight={1}
color="#80BA80"
interactive
ref={(ref) => {
if (ref != null) {
pathLine.current = ref;
}
}}
/>
</>
);
}
interface SecondaryMarkerProps {
controlPoint: ControlPointModel;
destination: LatLng | null;
}
function SecondaryMarker(props: SecondaryMarkerProps) {
if (!props.destination) {
return <></>;
}
return (
<Marker
position={props.controlPoint.position}
@ -31,18 +243,28 @@ export default function ControlPoint(props: ControlPointProps) {
zIndexOffset={1000}
eventHandlers={{
click: () => {
backend.post(`/qt/info/control-point/${props.controlPoint.id}`);
openInfoDialog(props.controlPoint);
},
contextmenu: () => {
backend.post(
`/qt/create-package/control-point/${props.controlPoint.id}`
);
openNewPackageDialog(props.controlPoint);
},
}}
>
<Tooltip>
<h3 style={{ margin: 0 }}>{props.controlPoint.name}</h3>
<LocationTooltipText {...props} />
</Tooltip>
</Marker>
);
}
export default function ControlPoint(props: ControlPointProps) {
return (
<>
<PrimaryMarker {...props} />
<SecondaryMarker
controlPoint={props.controlPoint}
destination={props.controlPoint.destination}
/>
</>
);
}

View File

@ -1,8 +1,13 @@
from dcs import Point
from dcs.mapping import LatLng
from fastapi import APIRouter, Depends, HTTPException, status
from game import Game
from .models import ControlPointJs
from .. import EventStream
from ..dependencies import GameContext
from ..leaflet import LeafletPoint
from ...sim import GameUpdateEvents
router: APIRouter = APIRouter(prefix="/control-points")
@ -26,3 +31,67 @@ def get_control_point(
detail=f"Game has no control point with ID {cp_id}",
)
return ControlPointJs.for_control_point(cp)
@router.get("/{cp_id}/destination-in-range")
def destination_in_range(
cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.get)
) -> bool:
cp = game.theater.find_control_point_by_id(cp_id)
if cp is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"Game has no control point with ID {cp_id}",
)
point = Point.from_latlng(LatLng(lat, lng), game.theater.terrain)
return cp.destination_in_range(point)
@router.put("/{cp_id}/destination")
def set_destination(
cp_id: int, destination: LeafletPoint, game: Game = Depends(GameContext.get)
) -> None:
cp = game.theater.find_control_point_by_id(cp_id)
if cp is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"Game has no control point with ID {cp_id}",
)
if not cp.moveable:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{cp} is not mobile")
if not cp.captured:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=f"{cp} is not owned by the player"
)
point = Point.from_latlng(
LatLng(destination.lat, destination.lng), game.theater.terrain
)
if not cp.destination_in_range(point):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Cannot move {cp} more than "
f"{cp.max_move_distance.nautical_miles}nm.",
)
cp.target_position = point
EventStream.put_nowait(GameUpdateEvents().update_control_point(cp))
@router.put("/{cp_id}/cancel-travel")
def cancel_travel(cp_id: int, game: Game = Depends(GameContext.get)) -> None:
cp = game.theater.find_control_point_by_id(cp_id)
if cp is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"Game has no control point with ID {cp_id}",
)
if not cp.moveable:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{cp} is not mobile")
if not cp.captured:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=f"{cp} is not owned by the player"
)
cp.target_position = None
EventStream.put_nowait(GameUpdateEvents().update_control_point(cp))

View File

@ -41,7 +41,7 @@ from game.sidc import (
Status,
SymbolSet,
)
from game.utils import Heading
from game.utils import Distance, Heading, meters
from .base import Base
from .frontline import FrontLine
from .missiontarget import MissionTarget
@ -540,7 +540,15 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
"""
:return: Whether this control point can be moved around
"""
return False
return self.max_move_distance > meters(0)
@property
def max_move_distance(self) -> Distance:
return meters(0)
def destination_in_range(self, destination: Point) -> bool:
distance = meters(destination.distance_to_point(self.position))
return distance <= self.max_move_distance
@property
@abstractmethod
@ -1117,8 +1125,8 @@ class NavalControlPoint(ControlPoint, ABC):
return False
@property
def moveable(self) -> bool:
return True
def max_move_distance(self) -> Distance:
return nautical_miles(80)
@property
def can_deploy_ground_units(self) -> bool: