mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Support mobile CPs in the new map.
https://github.com/dcs-liberation/dcs_liberation/issues/2039
This commit is contained in:
parent
2c6e8c414c
commit
995e28cb32
@ -6,11 +6,35 @@ import { LatLng } from "leaflet";
|
|||||||
export const apiSlice = createApi({
|
export const apiSlice = createApi({
|
||||||
reducerPath: "api",
|
reducerPath: "api",
|
||||||
baseQuery: fetchBaseQuery({ baseUrl: HTTP_URL }),
|
baseQuery: fetchBaseQuery({ baseUrl: HTTP_URL }),
|
||||||
|
tagTypes: ["ControlPoint"],
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getCommitBoundaryForFlight: builder.query<LatLng[], string>({
|
getCommitBoundaryForFlight: builder.query<LatLng[], string>({
|
||||||
query: (flightId) => `flights/${flightId}/commit-boundary`,
|
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;
|
||||||
|
|||||||
@ -1,8 +1,26 @@
|
|||||||
|
import {
|
||||||
|
useControlPointCancelTravelMutation,
|
||||||
|
useSetControlPointDestinationMutation,
|
||||||
|
} from "../../api/api";
|
||||||
import backend from "../../api/backend";
|
import backend from "../../api/backend";
|
||||||
import { ControlPoint as ControlPointModel } from "../../api/controlpoint";
|
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 { 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) {
|
function iconForControlPoint(cp: ControlPointModel) {
|
||||||
const symbol = new MilSymbol(cp.sidc, {
|
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 {
|
interface ControlPointProps {
|
||||||
controlPoint: ControlPointModel;
|
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}°${ns} ${lng}°${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 (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
position={props.controlPoint.position}
|
position={props.controlPoint.position}
|
||||||
@ -31,18 +243,28 @@ export default function ControlPoint(props: ControlPointProps) {
|
|||||||
zIndexOffset={1000}
|
zIndexOffset={1000}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => {
|
click: () => {
|
||||||
backend.post(`/qt/info/control-point/${props.controlPoint.id}`);
|
openInfoDialog(props.controlPoint);
|
||||||
},
|
},
|
||||||
contextmenu: () => {
|
contextmenu: () => {
|
||||||
backend.post(
|
openNewPackageDialog(props.controlPoint);
|
||||||
`/qt/create-package/control-point/${props.controlPoint.id}`
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<h3 style={{ margin: 0 }}>{props.controlPoint.name}</h3>
|
<LocationTooltipText {...props} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ControlPoint(props: ControlPointProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PrimaryMarker {...props} />
|
||||||
|
<SecondaryMarker
|
||||||
|
controlPoint={props.controlPoint}
|
||||||
|
destination={props.controlPoint.destination}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
from dcs import Point
|
||||||
|
from dcs.mapping import LatLng
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from game import Game
|
from game import Game
|
||||||
from .models import ControlPointJs
|
from .models import ControlPointJs
|
||||||
|
from .. import EventStream
|
||||||
from ..dependencies import GameContext
|
from ..dependencies import GameContext
|
||||||
|
from ..leaflet import LeafletPoint
|
||||||
|
from ...sim import GameUpdateEvents
|
||||||
|
|
||||||
router: APIRouter = APIRouter(prefix="/control-points")
|
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}",
|
detail=f"Game has no control point with ID {cp_id}",
|
||||||
)
|
)
|
||||||
return ControlPointJs.for_control_point(cp)
|
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))
|
||||||
|
|||||||
@ -41,7 +41,7 @@ from game.sidc import (
|
|||||||
Status,
|
Status,
|
||||||
SymbolSet,
|
SymbolSet,
|
||||||
)
|
)
|
||||||
from game.utils import Heading
|
from game.utils import Distance, Heading, meters
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from .frontline import FrontLine
|
from .frontline import FrontLine
|
||||||
from .missiontarget import MissionTarget
|
from .missiontarget import MissionTarget
|
||||||
@ -540,7 +540,15 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
|
|||||||
"""
|
"""
|
||||||
:return: Whether this control point can be moved around
|
: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
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -1117,8 +1125,8 @@ class NavalControlPoint(ControlPoint, ABC):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def moveable(self) -> bool:
|
def max_move_distance(self) -> Distance:
|
||||||
return True
|
return nautical_miles(80)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_deploy_ground_units(self) -> bool:
|
def can_deploy_ground_units(self) -> bool:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user