feat: Improved formation menu

This commit is contained in:
Davide Passoni
2025-01-13 09:36:43 +01:00
parent 0376d020e7
commit 711f6094f0
22 changed files with 48261 additions and 42134 deletions

View File

@@ -1,19 +1,19 @@
import { useCallback, useEffect, useState } from "react";
export const useDrag = (props: { ref, initialPosition, count}) => {
const [finalPosition, setFinalPosition] = useState({ x: props.initialPosition.x, y: props.initialPosition.y });
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: props.initialPosition.x, y: props.initialPosition.y });
const [dragging, isDragging] = useState(false);
const [count, setCount] = useState(0)
if (count !== props.count) {
setCount(props.count)
setFinalPosition({ x: props.initialPosition.x, y: props.initialPosition.y })
setPosition({ x: props.initialPosition.x, y: props.initialPosition.y })
}
const handleMouseUp = (evt) => {
evt.preventDefault();
setIsDragging(false);
isDragging(false);
};
const handleMouseDown = (evt) => {
@@ -25,14 +25,14 @@ export const useDrag = (props: { ref, initialPosition, count}) => {
return;
}
setIsDragging(true);
isDragging(true);
};
const handleMouseMove = useCallback(
(evt) => {
const { current: draggableElement } = props.ref;
if (!isDragging || !draggableElement) return;
if (!dragging || !draggableElement) return;
evt.preventDefault();
@@ -43,12 +43,12 @@ export const useDrag = (props: { ref, initialPosition, count}) => {
const [mouseX, mouseY] = [evt.clientX, evt.clientY];
const [parentTop, parentLeft, parentWidth, parentHeight] = [parentRect.top, parentRect.left, parentRect.width, parentRect.height];
setFinalPosition({
x: Math.round(Math.max(width / 2, Math.min(mouseX - parentLeft, parentWidth - width / 2)) / 10) * 10,
y: Math.round(Math.max(height / 2, Math.min(mouseY - parentTop, parentHeight - height / 2)) / 10) * 10,
setPosition({
x: Math.max(width / 2, Math.min(mouseX - parentLeft, parentWidth - width / 2)),
y: Math.max(height / 2, Math.min(mouseY - parentTop, parentHeight - height / 2)),
});
},
[isDragging, props.ref]
[dragging, props.ref]
);
useEffect(() => {
@@ -61,8 +61,13 @@ export const useDrag = (props: { ref, initialPosition, count}) => {
};
}, [handleMouseMove]);
const forcePosition = (x, y) => {
setPosition({x, y});
}
return {
position: finalPosition,
handleMouseDown
position: position,
handleMouseDown,
forcePosition
};
};

View File

@@ -21,7 +21,7 @@ import { MissionData, UnitData } from "../../interfaces";
export function ImportExportModal(props: { open: boolean }) {
const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED);
const [appSubState, setAppSubState] = useState(NO_SUBSTATE);
const [units, setUnits] = useState({} as { [ID: number]: Unit });
const [units, setUnits] = useState([] as Unit[]);
const [missionData, setMissionData] = useState({} as MissionData);
const [importData, setImportData] = useState({} as { [key: string]: UnitData[] });
@@ -86,7 +86,7 @@ export function ImportExportModal(props: { open: boolean }) {
}
}, [appState, appSubState]);
const selectableUnits = Object.values(units).filter((unit) => {
const selectableUnits = units.filter((unit) => {
return (
unit.getAlive() &&
!unit.getHuman() &&

View File

@@ -0,0 +1,62 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
export function Draggable(props: {
position: { x: number; y: number };
children: JSX.Element | JSX.Element[];
disabled: boolean;
onPositionChange: (position: { x: number; y: number }) => void;
}) {
const [dragging, setDragging] = useState(false);
const [refPosition, setRefPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = useCallback(
(e) => {
if (dragging) {
e.stopPropagation();
e.preventDefault();
setRefPosition({ x: e.clientX, y: e.clientY });
if (!props.disabled) props.onPositionChange({ x: props.position.x + e.clientX - refPosition.x, y: props.position.y + e.clientY - refPosition.y });
}
},
[dragging, refPosition]
);
const handleMouseUp = useCallback(
(e) => {
if (dragging) {
e.stopPropagation();
e.preventDefault();
setDragging(false);
}
},
[dragging, refPosition]
);
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
return (
<div
className={`absolute translate-x-[-50%] translate-y-[-50%]`}
style={{
top: props.position.y,
left: props.position.x,
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
setRefPosition({ x: e.clientX, y: e.clientY });
setDragging(true);
}}
>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Draggable } from "./draggable";
import { Unit } from "../../../unit/unit";
export function DraggableSilhouette(props: {
position: { x: number; y: number };
unit: Unit;
zoom: number;
scale: number;
disabled: boolean;
angle: number;
onPositionChange: (position: { x: number; y: number }) => void;
src?: string;
}) {
return (
<Draggable position={props.position} onPositionChange={props.onPositionChange} disabled={props.disabled}>
<img
data-disabled = {props.disabled}
className={`
align-center opacity-80 invert
data-[disabled=false]:cursor-move
`}
src={props.src ?? `./images/units/${props.unit?.getBlueprint()?.filename}`}
style={{
maxWidth: `${Math.round((props.scale * (props.disabled ? 20 : (props.unit?.getBlueprint()?.length ?? 50))) / Math.min(3, props.disabled? 1: props.zoom))}px`,
minWidth: `${Math.round((props.scale * (props.disabled ? 20 : (props.unit?.getBlueprint()?.length ?? 50))) / Math.min(3, props.disabled? 1: props.zoom))}px`,
rotate: `${props.disabled? props.angle: 90}deg`,
}}
></img>
</Draggable>
);
}

View File

@@ -0,0 +1,259 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Unit } from "../../../unit/unit";
import { DraggableSilhouette } from "./draggablesilhouette";
import { FaCompressArrowsAlt, FaExclamationTriangle, FaExpand, FaExpandArrowsAlt } from "react-icons/fa";
const FT_TO_PX = 1;
export function FormationCanvas(props: {
units: Unit[];
unitPositions: { x: number; y: number }[];
setUnitPositions: (positions: { x: number; y: number }[]) => void;
}) {
const [dragging, setDragging] = useState(false);
const [refPosition, setRefPosition] = useState({ x: 0, y: 0 });
const [dragDelta, setDragDelta] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
/* Init references and hooks */
const containerRef = useRef(null);
let containerCenter = { x: 0, y: 0 };
let containerSize = { width: 0, height: 0 };
if (containerRef.current) {
const containerDiv = containerRef.current as HTMLDivElement;
containerCenter = {
x: containerDiv.clientWidth / 2,
y: containerDiv.clientHeight / 3,
};
containerSize = { width: containerDiv.clientWidth, height: containerDiv.clientHeight };
}
/* Handle mouse movement, for dragging of the scene */
const handleMouseMove = useCallback(
(e) => {
if (dragging) {
e.stopPropagation();
e.preventDefault();
setDragDelta({
x: dragDelta.x + e.clientX - refPosition.x,
y: dragDelta.y + e.clientY - refPosition.y,
});
setRefPosition({ x: e.clientX, y: e.clientY });
}
},
[dragging, refPosition]
);
/* Handle mouse up, to stop dragging the scene */
const handleMouseUp = useCallback(
(e) => {
if (dragging) {
e.stopPropagation();
e.preventDefault();
setDragging(false);
}
},
[dragging, refPosition]
);
/* Register the dragging handlers */
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
let referenceDistance = 200 * zoom;
if (referenceDistance < 250) referenceDistance = 100;
else if (referenceDistance < 500) referenceDistance = 250;
else if (referenceDistance < 1000) referenceDistance = 500;
else if (referenceDistance < 3000) referenceDistance = 1000;
else if (referenceDistance < 5280 * 2) referenceDistance = 5280;
else referenceDistance = 5280 * 2;
const referenceWidth = referenceDistance / zoom;
return (
<>
<div className="flex w-fit gap-1 p-1">
<button
type="button"
onDoubleClick={(e) => {
e.stopPropagation();
}}
onClick={() => {
props.setUnitPositions(
props.unitPositions.map((position) => {
return {
x: position.x * 1.1,
y: position.y * 1.1,
};
})
);
}}
className={`
rounded-lg p-2 text-md flex content-center justify-center gap-2
bg-gray-600 font-medium text-white
hover:bg-gray-700
`}
>
<FaExpandArrowsAlt className="my-auto" /> <div> Loosen </div>
</button>
<button
type="button"
onDoubleClick={(e) => {
e.stopPropagation();
}}
onClick={() => {
props.setUnitPositions(
props.unitPositions.map((position) => {
return {
x: position.x * 0.9,
y: position.y * 0.9,
};
})
);
}}
className={`
rounded-lg p-2 text-md flex content-center justify-center gap-2
bg-gray-600 font-medium text-white
hover:bg-gray-700
`}
>
<FaCompressArrowsAlt className="my-auto" /> <div>Tighten</div>
</button>
</div>
<div
data-dragging={dragging}
className={`
relative h-full w-full cursor-grab overflow-hidden rounded-md
border-[1px] border-white/20 bg-white/10
data-[dragging=true]:cursor-grabbing
`}
onWheel={(e) => {
if (e.deltaY > 0) setZoom(Math.max(Math.min(zoom * 1.1, 100), 0.8));
else setZoom(Math.max(Math.min(zoom * 0.9, 100), 0.8));
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
setRefPosition({ x: e.clientX, y: e.clientY });
setDragging(true);
}}
onDoubleClick={() => {
setDragDelta({ x: 0, y: 0 });
setZoom(1);
}}
>
<div className={`h-full w-full`} ref={containerRef}>
{props.units.map((unit, idx) => {
let unitPosition = props.unitPositions[idx]
? {
x:
props.unitPositions[0].x +
(((props.unitPositions[idx].x - props.unitPositions[0].x) * 1) / zoom) * FT_TO_PX +
dragDelta.x +
containerCenter.x,
y:
props.unitPositions[0].y +
(((props.unitPositions[idx].y - props.unitPositions[0].y) * 1) / zoom) * FT_TO_PX +
dragDelta.y +
containerCenter.y,
}
: { x: 0, y: 0 };
let disabled = false;
let overflowX = null as null | string;
let overflowY = null as null | string;
if (unitPosition.x < 0) {
disabled = true;
unitPosition.x = 10;
overflowX = "left";
} else if (unitPosition.x > containerSize.width) {
disabled = true;
unitPosition.x = containerSize.width - 10;
overflowX = "right";
}
if (unitPosition.y < 0) {
disabled = true;
unitPosition.y = 10;
overflowY = "top";
} else if (unitPosition.y > containerSize.height) {
disabled = true;
unitPosition.y = containerSize.height - 10;
overflowY = "bottom";
}
let angle = 0;
if (overflowX === "right") {
if (overflowY === "top") angle = 45;
else if (overflowY === "bottom") angle = 135;
else angle = 90;
} else if (overflowX === "left") {
if (overflowY === "top") angle = 360 - 45;
else if (overflowY === "bottom") angle = 360 - 135;
else angle = 360 - 90;
} else {
if (overflowY === "top") angle = 0;
else if (overflowY === "bottom") angle = 180;
else angle = 0;
}
return (
<DraggableSilhouette
key={idx}
zoom={zoom}
position={unitPosition}
unit={unit}
scale={FT_TO_PX}
disabled={disabled}
onPositionChange={({ x, y }) => {
props.unitPositions[idx] = {
x: ((x - props.unitPositions[0].x - dragDelta.x - containerCenter.x) * zoom) / FT_TO_PX - props.unitPositions[0].x,
y: ((y - props.unitPositions[0].y - dragDelta.y - containerCenter.y) * zoom) / FT_TO_PX - props.unitPositions[0].y,
};
props.setUnitPositions([...props.unitPositions]);
}}
src={disabled ? `./images/others/caret.svg` : undefined}
angle={angle}
/>
);
})}
</div>
{zoom > 3 && (
<div className="absolute bottom-2 left-2 flex gap-2">
<FaExclamationTriangle className={`text-xl text-yellow-400`} />
<div className="text-white">Silhouettes not to scale!</div>
</div>
)}
<div className="absolute left-0 top-2 m-[-0.75rem] h-0">
<div
className={`
relative left-6 top-4 h-4 border-2 border-white
border-t-transparent text-center text-white
`}
style={{
width: `${referenceWidth}px`,
}}
>
{referenceDistance === 5280 && <div className="translate-y-[-8px]">1 NM</div>}
{referenceDistance === 5280 * 2 && (
<div
className={`translate-y-[-8px]`}
>
2 NM
</div>
)}
{referenceDistance < 5280 && <div className="translate-y-[-8px]">{referenceDistance} ft</div>}
</div>
</div>
</div>
</>
);
}

View File

@@ -2,13 +2,12 @@ import React, { useEffect, useState } from "react";
import { OlLocation } from "../components/ollocation";
import { LatLng } from "leaflet";
import { FaBullseye, FaChevronDown, FaChevronUp, FaJetFighter, FaMountain } from "react-icons/fa6";
import { BullseyesDataChangedEvent, MouseMovedEvent, SelectedUnitsChangedEvent } from "../../events";
import { BullseyesDataChangedEvent, MouseMovedEvent, SelectedUnitsChangedEvent, SelectionClearedEvent } from "../../events";
import { computeBearingRangeString, mToFt } from "../../other/utils";
import { Bullseye } from "../../mission/bullseye";
import { Unit } from "../../unit/unit";
export function CoordinatesPanel(props: {}) {
const [open, setOpen] = useState(false);
const [latlng, setLatlng] = useState(new LatLng(0, 0));
const [elevation, setElevation] = useState(0);
const [bullseyes, setBullseyes] = useState(null as null | { [name: string]: Bullseye });
@@ -22,6 +21,7 @@ export function CoordinatesPanel(props: {}) {
BullseyesDataChangedEvent.on((bullseyes) => setBullseyes(bullseyes));
SelectedUnitsChangedEvent.on((selectedUnits) => setSelectedUnits(selectedUnits));
SelectionClearedEvent.on(() => setSelectedUnits([]))
}, []);
return (
@@ -33,59 +33,53 @@ export function CoordinatesPanel(props: {}) {
dark:bg-olympus-800/90 dark:text-gray-200
`}
>
{" "}
{open && (
<>
{bullseyes && (
<div className="flex w-full items-center justify-start">
<div
{bullseyes && (
<div className="flex w-full items-center justify-start">
<div
className={`
mr-[11px] flex min-w-64 max-w-64 items-center justify-between
gap-2
`}
>
<div className="flex justify-start gap-2">
<span
className={`
flex min-w-64 max-w-64 items-center justify-between gap-2
rounded-sm bg-blue-500 px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<div className="flex justify-start gap-2">
<span
className={`
rounded-sm bg-blue-500 px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<FaBullseye />
</span>{" "}
{computeBearingRangeString(bullseyes[2].getLatLng(), latlng)}
</div>
<div className="flex w-[50%] justify-start gap-2">
<span
className={`
rounded-sm bg-red-500 px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<FaBullseye />
</span>
{computeBearingRangeString(bullseyes[1].getLatLng(), latlng)}
</div>
</div>
{selectedUnits.length == 1 && (
<div className="flex justify-start gap-2">
<span
className={`
rounded-sm bg-white px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<FaJetFighter />
</span>
<div>
{" "}
{computeBearingRangeString(selectedUnits[0].getPosition(), latlng)}
</div>
</div>
)}
<FaBullseye />
</span>{" "}
{computeBearingRangeString(bullseyes[2].getLatLng(), latlng)}
</div>
<div className="flex w-[50%] justify-start gap-2">
<span
className={`
rounded-sm bg-red-500 px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<FaBullseye />
</span>
{computeBearingRangeString(bullseyes[1].getLatLng(), latlng)}
</div>
</div>
{selectedUnits.length == 1 && (
<div className="flex justify-start gap-2">
<span
className={`
rounded-sm bg-white px-1 py-1 text-center font-bold
text-olympus-700
`}
>
<FaJetFighter />
</span>
<div> {computeBearingRangeString(selectedUnits[0].getPosition(), latlng)}</div>
</div>
)}
</>
</div>
)}
<div className="flex w-full items-center justify-between">
<OlLocation className="!min-w-64 !max-w-64 bg-transparent !p-0" location={latlng} />
<span
@@ -97,11 +91,6 @@ export function CoordinatesPanel(props: {}) {
<FaMountain />
</span>
<div className="min-w-12">{mToFt(elevation).toFixed()}ft</div>
{open ? (
<FaChevronDown className="w-10 cursor-pointer" onClick={() => setOpen(!open)} />
) : (
<FaChevronUp className="w-10 cursor-pointer" onClick={() => setOpen(!open)} />
)}
</div>
</div>
);

View File

@@ -1,106 +1,55 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Menu } from "./components/menu";
import { OlDropdown, OlDropdownItem } from "../components/oldropdown";
import { useDrag } from "../libs/useDrag";
import { Unit } from "../../unit/unit";
import { OlRangeSlider } from "../components/olrangeslider";
import { FormationCreationRequestEvent } from "../../events";
import { computeStandardFormationOffset } from "../../other/utils";
import { formationTypes } from "../../constants/constants";
import { FormationCanvas } from "./components/formationcanvas";
export function FormationMenu(props: {
open: boolean;
onClose: () => void;
children?: JSX.Element | JSX.Element[];
}) {
const [leader, setLeader] = useState(null as Unit | null)
const [wingmen, setWingmen] = useState(null as Unit[] | null)
/* The useDrag custom hook used to handle the dragging of the units requires that the number of hooks remains unchanged.
The units array is therefore initialized to 128 units maximum. */
let units = Array(128).fill(null) as (Unit | null)[];
units[0] = leader;
wingmen?.forEach((unit, idx) => {
if (idx < units.length) units[idx + 1] = unit;
});
export function FormationMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
const [leader, setLeader] = useState(null as Unit | null);
const [wingmen, setWingmen] = useState([] as Unit[]);
/* Init state variables */
const [formationType, setFormationType] = useState("echelon-lh");
const [horizontalScale, setHorizontalScale] = useState(0);
const [verticalScale, setVerticalScale] = useState(30);
const [offsets, setOffsets] = useState(
units.map((unit, idx) => {
return computeFormationOffset(formationType, idx);
})
);
const [unitPositions, setUnitPositions] = useState([] as { x: number; y: number }[]);
/* The count state is used to force the reset of the initial position of the silhouettes */
// TODO it works but I don't like it, it feels like a hack
const [count, setCount] = useState(0);
/* Init references and hooks */
const containerRef = useRef(null);
const scrollRef = useRef(null);
const silhouetteReferences = units.map((_) => useRef(null));
const silhouetteHandles = units.map((_, idx) => {
/* Set the initial position of the unit to be centered in the drawing canvas, depending on the currently loaded formation */
let offset = offsets[idx] ?? { x: 0, y: 0, z: 0 };
let center = { x: 0, y: 0 };
if (containerRef.current) {
center.x = (containerRef.current as HTMLDivElement).getBoundingClientRect().width / 2;
center.y = (containerRef.current as HTMLDivElement).getBoundingClientRect().height / 2;
}
return useDrag({
ref: silhouetteReferences[idx],
initialPosition: { x: offset.z + center.x, y: -offset.x + center.y },
count: count,
});
});
const verticalRatio = (verticalScale - 50) / 50;
/* Listen for the setting of a new leader and wingmen and check if the formation is too big */
useEffect(() => {
FormationCreationRequestEvent.on((leader, wingmen) => {
setLeader(leader);
setWingmen(wingmen);
})
})
});
}, []);
/* When the formation type is changed, reset the position to the center and the position of the silhouettes depending on the aircraft */
useEffect(() => {
if (scrollRef.current && containerRef.current) {
const containerDiv = containerRef.current as HTMLDivElement;
const scrollDiv = scrollRef.current as HTMLDivElement;
scrollDiv.scrollTop = (containerDiv.clientHeight - scrollDiv.clientHeight) / 2 + 150;
scrollDiv.scrollLeft = (containerDiv.clientWidth - scrollDiv.clientWidth) / 2;
}
const setStandardFormation = useCallback(() => {
/* If a standard formation is chosen, compute the positions */
if (formationType !== "custom") {
setOffsets(
units.map((unit, idx) => {
return computeFormationOffset(formationType, idx);
setUnitPositions(
[leader, ...wingmen].map((unit, idx) => {
return computeStandardFormationOffset(formationType, idx);
})
);
setCount(count + 1);
}
}, [formationType]);
useEffect(setStandardFormation, [formationType]);
const horizontalRatio = 1 + (horizontalScale / 100) ** 2 * 100;
const verticalRatio = (verticalScale - 50) / 50;
let referenceDistance = 200 * horizontalRatio;
if (referenceDistance < 250) {
referenceDistance = 100;
} else if (referenceDistance < 500) {
referenceDistance = 250;
} else if (referenceDistance < 1000) {
referenceDistance = 500;
} else if (referenceDistance < 3000) {
referenceDistance = 1000;
} else if (referenceDistance < 10000) {
referenceDistance = 5000;
} else {
referenceDistance = 10000;
if (leader && unitPositions.length < [leader, ...wingmen].length) {
/* If more units are added to the group keep the existing positions */
setUnitPositions(
[leader, ...wingmen].map((unit, idx) => {
if (idx < unitPositions.length) return unitPositions[idx];
else return computeStandardFormationOffset(formationType, idx);
})
);
}
const referenceWidth = referenceDistance / horizontalRatio;
return (
<Menu title="Formation menu" open={props.open} showBackButton={false} onClose={props.onClose}>
<div className="flex h-full flex-col gap-4 p-4">
@@ -111,13 +60,7 @@ export function FormationMenu(props: {
.filter((type) => type !== "custom")
.map((optionFormationType) => {
return (
<OlDropdownItem
key={optionFormationType}
onClick={() => {
setCount(count + 1);
setFormationType(optionFormationType);
}}
>
<OlDropdownItem key={optionFormationType} onClick={() => setFormationType(optionFormationType)}>
{formationTypes[optionFormationType]}
</OlDropdownItem>
);
@@ -126,25 +69,7 @@ export function FormationMenu(props: {
<button
type="button"
onClick={() => {
let content = JSON.stringify(
units
.filter((unit) => unit !== null)
.map((unit, idx) => {
if (units.length > 0 && units[0] !== null) {
const [dx, dz] = [
-(silhouetteHandles[idx].position.y - silhouetteHandles[0].position.y),
silhouetteHandles[idx].position.x - silhouetteHandles[0].position.x,
];
const distance = Math.sqrt(dx ** 2 + dz ** 2);
const offset = {
x: dx * horizontalRatio,
y: distance * verticalRatio,
z: dz * horizontalRatio,
};
return offset;
}
})
);
let content = JSON.stringify(unitPositions);
var a = document.createElement("a");
var file = new Blob([content], { type: "text/plain" });
a.href = URL.createObjectURL(file);
@@ -176,8 +101,7 @@ export function FormationMenu(props: {
// @ts-ignore TODO
var content = readerEvent.target.result;
if (content) {
setOffsets(JSON.parse(content.toString()));
setCount(count + 1);
setUnitPositions(JSON.parse(content.toString()));
setFormationType("custom");
}
};
@@ -196,24 +120,6 @@ export function FormationMenu(props: {
Load
</button>
</div>
<span className="text-white">Formation distance</span>
<div className="flex h-fit content-center gap-4">
<span
className={`
my-auto min-w-16 text-center align-middle text-sm text-white
`}
>
Parade
</span>
<OlRangeSlider
className="my-auto"
value={horizontalScale}
onChange={(ev) => {
setHorizontalScale(Number(ev.target.value));
}}
/>
<span className="my-auto min-w-16 text-center text-sm text-white">Tactical</span>
</div>
<span className="text-white">Vertical separation</span>
<div className="flex h-fit content-center gap-4">
<span className="ml-auto min-w-16 text-center text-sm text-white">Down</span>
@@ -226,39 +132,36 @@ export function FormationMenu(props: {
/>
<span className="my-auto min-w-16 text-center text-sm text-white">Up</span>
</div>
<FormationCanvas
units={leader ? [leader, ...wingmen] : []}
unitPositions={unitPositions}
setUnitPositions={(positions) => {
setUnitPositions(positions);
setFormationType("custom");
}}
/>
<button
type="button"
onClick={() => {
let center = { x: 0, y: 0 };
if (containerRef.current) {
center.x = (containerRef.current as HTMLDivElement).getBoundingClientRect().width / 2;
center.y = (containerRef.current as HTMLDivElement).getBoundingClientRect().height / 2;
if (leader) {
[leader, ...wingmen]
.filter((unit) => unit !== null)
.forEach((unit, idx) => {
if (idx != 0) {
const [dx, dz] = [-(unitPositions[idx].y - unitPositions[0].y), unitPositions[idx].x - unitPositions[0].x];
const distance = Math.sqrt(dx ** 2 + dz ** 2);
const offset = {
x: dx,
y: distance * verticalRatio,
z: dz,
};
unit.followUnit(leader.ID, offset);
}
});
}
units
.filter((unit) => unit !== null)
.forEach((unit, idx) => {
if (units.length > 0 && units[0] !== null && idx != 0) {
const ID = units[0].ID;
const [dx, dz] = [
-(silhouetteHandles[idx].position.y - silhouetteHandles[0].position.y),
silhouetteHandles[idx].position.x - silhouetteHandles[0].position.x,
];
const distance = Math.sqrt(dx ** 2 + dz ** 2);
const offset = {
x: dx * horizontalRatio,
y: distance * verticalRatio,
z: dz * horizontalRatio,
};
unit.followUnit(ID, offset);
}
});
}}
className={`
mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-md font-medium
text-white
rounded-lg bg-blue-700 px-5 py-2.5 text-md font-medium text-white
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
@@ -266,158 +169,7 @@ export function FormationMenu(props: {
>
Apply
</button>
<div className="relative m-[-0.75rem] h-0">
<div
className={`
relative left-6 top-4 h-4 border-2 border-white
border-t-transparent text-center text-white
`}
style={{
width: `${referenceWidth}px`,
}}
>
<div className="translate-y-[-8px]">{referenceDistance}ft</div>
</div>
</div>
<div
className={`
relative h-full w-full overflow-scroll rounded-md border-[1px]
border-white/20 bg-white/10
`}
ref={scrollRef}
>
<div className={`h-[1000px] w-[1000px] h-max-[1000px] w-max-[1000px]`} ref={containerRef}>
<>
{Array(100)
.fill(0)
.map((_, idx) => {
return (
<div
key={idx}
className={`
absolute top-0 h-[1000px] w-[1px] border-[1px]
border-white/10
`}
style={{
left: `${idx * 10}px`,
}}
></div>
);
})}
</>
<>
{Array(100)
.fill(0)
.map((_, idx) => {
return (
<div
key={idx}
className={`
absolute left-0 h-[1px] w-[1000px] border-[1px]
border-white/5
`}
style={{
top: `${idx * 10}px`,
}}
></div>
);
})}
</>
<>
{units.map((unit, idx) => {
return (
<div
key={`${count}-${idx}`}
className={`
absolute
${unit ? "" : "hidden"}
`}
ref={silhouetteReferences[idx]}
style={{
top: silhouetteHandles[idx].position.y,
left: silhouetteHandles[idx].position.x,
}}
onMouseDown={(e) => {
silhouetteHandles[idx].handleMouseDown(e);
setFormationType("custom");
}}
>
<img
className={`
h-10 min-h-10 w-10 min-w-10 translate-x-[-50%]
translate-y-[-50%] rotate-90 cursor-move opacity-80
invert
`}
src={`./images/units/${unit?.getBlueprint()?.filename}`}
></img>
</div>
);
})}
</>
</div>
</div>
</div>
</Menu>
);
}
function computeFormationOffset(formation, idx) {
let offset = { x: 0, z: 0 };
if (formation === "trail") {
offset.x = -50 * idx;
offset.z = 0;
} else if (formation === "echelon-lh") {
offset.x = -50 * idx;
offset.z = -50 * idx;
} else if (formation === "echelon-rh") {
offset.x = -50 * idx;
offset.z = 50 * idx;
} else if (formation === "line-abreast-lh") {
offset.x = 0;
offset.z = -50 * idx;
} else if (formation === "line-abreast-rh") {
offset.x = 0;
offset.z = 50 * idx;
} else if (formation === "front") {
offset.x = 100 * idx;
offset.z = 0;
} else if (formation === "diamond") {
var xr = 0;
var yr = 1;
var zr = -1;
var layer = 1;
for (let i = 0; i < idx; i++) {
var xl = xr * Math.cos(Math.PI / 4) - yr * Math.sin(Math.PI / 4);
var yl = xr * Math.sin(Math.PI / 4) + yr * Math.cos(Math.PI / 4);
offset = { x: -yl * 50, z: xl * 50 };
if (yr == 0) {
layer++;
xr = 0;
yr = layer;
zr = -layer;
} else {
if (xr < layer) {
xr++;
zr--;
} else {
yr--;
zr++;
}
}
}
}
return offset;
}
let formationTypes = {
"echelon-lh": "Echelon left",
"echelon-rh": "Echelon right",
"line-abreast-rh": "Line abreast right",
"line-abreast-lh": "Line abreast left",
trail: "Trail",
front: "Front",
diamond: "Diamond",
custom: "Custom",
};

View File

@@ -1,4 +1,4 @@
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
import React, { MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
import { Menu } from "./components/menu";
import { Unit } from "../../unit/unit";
import { OlLabelToggle } from "../components/ollabeltoggle";
@@ -51,37 +51,41 @@ import { FaCog, FaGasPump, FaSignal, FaTag } from "react-icons/fa";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OlSearchBar } from "../components/olsearchbar";
import { OlDropdown, OlDropdownItem } from "../components/oldropdown";
import { UnitBlueprint } from "../../interfaces";
import { FaRadio, FaVolumeHigh } from "react-icons/fa6";
import { OlNumberInput } from "../components/olnumberinput";
import { Radio, TACAN } from "../../interfaces";
import { OlStringInput } from "../components/olstringinput";
import { OlFrequencyInput } from "../components/olfrequencyinput";
import { UnitSink } from "../../audio/unitsink";
import { AudioManagerStateChangedEvent, SelectedUnitsChangedEvent, SelectionClearedEvent } from "../../events";
import { AudioManagerStateChangedEvent, SelectedUnitsChangedEvent, SelectionClearedEvent, UnitsUpdatedEvent } from "../../events";
export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
function initializeUnitsData() {
return {
desiredAltitude: undefined as undefined | number,
desiredAltitudeType: undefined as undefined | string,
desiredSpeed: undefined as undefined | number,
desiredSpeedType: undefined as undefined | string,
ROE: undefined as undefined | string,
reactionToThreat: undefined as undefined | string,
emissionsCountermeasures: undefined as undefined | string,
scenicAAA: undefined as undefined | boolean,
missOnPurpose: undefined as undefined | boolean,
shotsScatter: undefined as undefined | number,
shotsIntensity: undefined as undefined | number,
operateAs: undefined as undefined | Coalition,
followRoads: undefined as undefined | boolean,
isActiveAWACS: undefined as undefined | boolean,
isActiveTanker: undefined as undefined | boolean,
onOff: undefined as undefined | boolean,
isAudioSink: undefined as undefined | boolean,
};
}
const [selectedUnits, setSelectedUnits] = useState([] as Unit[]);
const [audioManagerState, setAudioManagerState] = useState(false);
const [selectedUnitsData, setSelectedUnitsData] = useState({
desiredAltitude: undefined as undefined | number,
desiredAltitudeType: undefined as undefined | string,
desiredSpeed: undefined as undefined | number,
desiredSpeedType: undefined as undefined | string,
ROE: undefined as undefined | string,
reactionToThreat: undefined as undefined | string,
emissionsCountermeasures: undefined as undefined | string,
scenicAAA: undefined as undefined | boolean,
missOnPurpose: undefined as undefined | boolean,
shotsScatter: undefined as undefined | number,
shotsIntensity: undefined as undefined | number,
operateAs: undefined as undefined | Coalition,
followRoads: undefined as undefined | boolean,
isActiveAWACS: undefined as undefined | boolean,
isActiveTanker: undefined as undefined | boolean,
onOff: undefined as undefined | boolean,
isAudioSink: undefined as undefined | boolean,
});
const [selectedUnitsData, setSelectedUnitsData] = useState(initializeUnitsData);
const [forcedUnitsData, setForcedUnitsData] = useState(initializeUnitsData);
const [selectionFilter, setSelectionFilter] = useState({
control: {
human: true,
@@ -115,6 +119,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
const [filterString, setFilterString] = useState("");
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [activeAdvancedSettings, setActiveAdvancedSettings] = useState(null as null | { radio: Radio; TACAN: TACAN });
const [lastUpdateTime, setLastUpdateTime] = useState(0);
var searchBarRef = useRef(null);
@@ -122,6 +127,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
SelectedUnitsChangedEvent.on((units) => setSelectedUnits(units));
SelectionClearedEvent.on(() => setSelectedUnits([]));
AudioManagerStateChangedEvent.on((state) => setAudioManagerState(state));
UnitsUpdatedEvent.on((units) => units.find((unit) => unit.getSelected()) && setLastUpdateTime(Date.now()));
}, []);
useEffect(() => {
@@ -130,7 +136,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
if (!props.open && filterString !== "") setFilterString("");
});
useEffect(() => {
const updateData = useCallback(() => {
setShowAdvancedSettings(false);
const getters = {
@@ -169,10 +175,24 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
} as { [key in keyof typeof selectedUnitsData]: (unit: Unit) => void };
var updatedData = {};
let anyForcedDataUpdated = false;
Object.entries(getters).forEach(([key, getter]) => {
updatedData[key] = getApp()?.getUnitsManager()?.getSelectedUnitsVariable(getter);
let newDatum = getApp()?.getUnitsManager()?.getSelectedUnitsVariable(getter);
if (forcedUnitsData[key] !== undefined) {
if (newDatum === updatedData[key]) {
anyForcedDataUpdated = true;
forcedUnitsData[key] === undefined;
}
updatedData[key] = forcedUnitsData[key];
} else updatedData[key] = newDatum;
});
setSelectedUnitsData(updatedData as typeof selectedUnitsData);
if (anyForcedDataUpdated) setForcedUnitsData({...forcedUnitsData})
}, [forcedUnitsData])
useEffect(updateData, [selectedUnits, lastUpdateTime, forcedUnitsData]);
useEffect(() => {
setForcedUnitsData(initializeUnitsData);
}, [selectedUnits]);
/* Count how many units are selected of each type, divided by coalition */
@@ -538,8 +558,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setAltitudeType(selectedUnitsData.desiredAltitudeType === "ASL" ? "AGL" : "ASL");
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
desiredAltitudeType: selectedUnitsData.desiredAltitudeType === "ASL" ? "AGL" : "ASL",
});
});
@@ -550,8 +570,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onChange={(ev) => {
selectedUnits.forEach((unit) => {
unit.setAltitude(ftToM(Number(ev.target.value)));
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
desiredAltitude: Number(ev.target.value),
});
});
@@ -600,8 +620,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setSpeedType(selectedUnitsData.desiredSpeedType === "CAS" ? "GS" : "CAS");
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
desiredSpeedType: selectedUnitsData.desiredSpeedType === "CAS" ? "GS" : "CAS",
});
});
@@ -613,8 +633,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onChange={(ev) => {
selectedUnits.forEach((unit) => {
unit.setSpeed(knotsToMs(Number(ev.target.value)));
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
desiredSpeed: Number(ev.target.value),
});
});
@@ -645,8 +665,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setROE(ROEs[convertROE(idx)]);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
ROE: ROEs[convertROE(idx)],
});
});
@@ -682,8 +702,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setReactionToThreat(reactionsToThreat[idx]);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
reactionToThreat: reactionsToThreat[idx],
});
});
@@ -714,8 +734,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setEmissionsCountermeasures(emissionsCountermeasures[idx]);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
emissionsCountermeasures: emissionsCountermeasures[idx],
});
});
@@ -756,8 +776,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
unit.getRadio(),
unit.getGeneralSettings()
);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
isActiveTanker: !selectedUnitsData.isActiveTanker,
});
});
@@ -790,8 +810,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
unit.getRadio(),
unit.getGeneralSettings()
);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
isActiveAWACS: !selectedUnitsData.isActiveAWACS,
});
});
@@ -848,8 +868,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
selectedUnitsData.scenicAAA ? unit.changeSpeed("stop") : unit.scenicAAA();
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
scenicAAA: !selectedUnitsData.scenicAAA,
missOnPurpose: false,
});
@@ -873,8 +893,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
selectedUnitsData.missOnPurpose ? unit.changeSpeed("stop") : unit.missOnPurpose();
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
scenicAAA: false,
missOnPurpose: !selectedUnitsData.missOnPurpose,
});
@@ -902,8 +922,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setShotsScatter(idx + 1);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
shotsScatter: idx + 1,
});
});
@@ -934,8 +954,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setShotsIntensity(idx + 1);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
shotsIntensity: idx + 1,
});
});
@@ -964,8 +984,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setOperateAs(selectedUnitsData.operateAs === "blue" ? "red" : "blue");
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
operateAs: selectedUnitsData.operateAs === "blue" ? "red" : "blue",
});
});
@@ -989,8 +1009,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setFollowRoads(!selectedUnitsData.followRoads);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
followRoads: !selectedUnitsData.followRoads,
});
});
@@ -1013,8 +1033,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
onClick={() => {
selectedUnits.forEach((unit) => {
unit.setOnOff(!selectedUnitsData.onOff);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
onOff: !selectedUnitsData.onOff,
});
});
@@ -1041,8 +1061,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
selectedUnits.forEach((unit) => {
if (!selectedUnitsData.isAudioSink) {
getApp()?.getAudioManager().addUnitSink(unit);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
isAudioSink: true,
});
} else {
@@ -1054,8 +1074,8 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
});
if (sink !== undefined) getApp()?.getAudioManager().removeSink(sink);
setSelectedUnitsData({
...selectedUnitsData,
setForcedUnitsData({
...forcedUnitsData,
isAudioSink: false,
});
}
@@ -1178,10 +1198,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
value={activeAdvancedSettings ? activeAdvancedSettings.TACAN.channel : 1}
></OlNumberInput>
<OlDropdown
label={activeAdvancedSettings ? activeAdvancedSettings.TACAN.XY : "X"}
className={`my-auto w-20`}
>
<OlDropdown label={activeAdvancedSettings ? activeAdvancedSettings.TACAN.XY : "X"} className={`
my-auto w-20
`}>
<OlDropdownItem
key={"X"}
onClick={() => {
@@ -1300,11 +1319,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
className={`
flex content-center gap-2 rounded-full
${selectedUnits[0].getFuel() > 40 && `bg-green-700`}
${
selectedUnits[0].getFuel() > 10 &&
selectedUnits[0].getFuel() <= 40 &&
`bg-yellow-700`
}
${selectedUnits[0].getFuel() > 10 && selectedUnits[0].getFuel() <= 40 && `
bg-yellow-700
`}
${selectedUnits[0].getFuel() <= 10 && `bg-red-700`}
px-2 py-1 text-sm font-bold text-white
`}