feat: Added unit spawn heading selection

This commit is contained in:
Davide Passoni 2025-01-24 10:55:57 +01:00
parent 6074367300
commit d1d4116e66
12 changed files with 330 additions and 19 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 40 40"
version="1.1"
id="svg1"
sodipodi:docname="arrow.svg"
width="40"
height="40"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="8.9824657"
inkscape:cx="20.985329"
inkscape:cy="21.430641"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<path
d="M 23.307123,3.5189202 C 22.873778,2.4788845 21.852318,1.8041019 20.725614,1.8041019 c -1.126705,0 -2.14817,0.6747826 -2.581515,1.7148183 L 5.2674896,34.22469 c -0.5200196,1.238134 -0.086679,2.661992 1.0214597,3.411065 1.1081335,0.749072 2.5938946,0.600492 3.5410687,-0.346667 L 20.725614,26.393493 31.621209,37.289088 c 0.947171,0.947172 2.426747,1.089559 3.541067,0.346667 1.114324,-0.742886 1.541478,-2.172931 1.021461,-3.411065 z"
id="path1"
style="stroke:#262626;stroke-width:2.30583;stroke-dasharray:none;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -558,6 +558,23 @@ export class SpawnContextMenuRequestEvent {
}
}
export class SpawnHeadingChangedEvent {
static on(callback: (heading: number) => void, singleShot = false) {
document.addEventListener(
this.name,
(ev: CustomEventInit) => {
callback(ev.detail.heading);
},
{ once: singleShot }
);
}
static dispatch(heading: number) {
document.dispatchEvent(new CustomEvent(this.name, { detail: { heading } }));
console.log(`Event ${this.name} dispatched`);
}
}
export class HotgroupsChangedEvent {
static on(callback: (hotgroups: { [key: number]: Unit[] }) => void, singleShot = false) {
document.addEventListener(

View File

@ -142,6 +142,7 @@ export interface UnitSpawnTable {
liveryID: string;
altitude?: number;
loadout?: string;
heading?: number;
}
export interface ObjectIconOptions {

View File

@ -56,6 +56,7 @@ import {
SelectionEnabledChangedEvent,
SessionDataLoadedEvent,
SpawnContextMenuRequestEvent,
SpawnHeadingChangedEvent,
StarredSpawnsChangedEvent,
UnitDeselectedEvent,
UnitSelectedEvent,
@ -144,6 +145,7 @@ export class Map extends L.Map {
#temporaryMarkers: TemporaryUnitMarker[] = [];
#currentSpawnMarker: TemporaryUnitMarker | null = null;
#currentEffectMarker: ExplosionMarker | SmokeMarker | null = null;
#spawnHeading: number = 0;
/* JTAC tools */
#ECHOPoint: TextMarker | null = null;
@ -565,6 +567,19 @@ export class Map extends L.Map {
this.#currentSpawnMarker = this.addTemporaryMarker(spawnRequestTable.unit.location, spawnRequestTable.unit.unitType, spawnRequestTable.coalition, true);
}
getSpawnRequestTable() {
return this.#spawnRequestTable;
}
setSpawnHeading(heading: number) {
this.#spawnHeading = heading;
SpawnHeadingChangedEvent.dispatch(heading);
}
getSpawnHeading() {
return this.#spawnHeading;
}
addStarredSpawnRequestTable(key, spawnRequestTable: SpawnRequestTable, quickAccessName: string) {
this.#starredSpawnRequestTables[key] = spawnRequestTable;
this.#starredSpawnRequestTables[key].quickAccessName = quickAccessName;
@ -833,7 +848,7 @@ export class Map extends L.Map {
new L.LatLng(0, 0),
this.#spawnRequestTable?.unit.unitType ?? "",
this.#spawnRequestTable?.coalition ?? "neutral",
false
true
);
this.#currentSpawnMarker.addTo(this);
} else if (subState === SpawnSubState.SPAWN_EFFECT) {
@ -923,6 +938,7 @@ export class Map extends L.Map {
if (getApp().getSubState() === SpawnSubState.SPAWN_UNIT) {
if (this.#spawnRequestTable !== null) {
this.#spawnRequestTable.unit.location = e.latlng;
this.#spawnRequestTable.unit.heading = deg2rad(this.#spawnHeading);
getApp()
.getUnitsManager()
.spawnUnits(
@ -937,6 +953,7 @@ export class Map extends L.Map {
e.latlng,
this.#spawnRequestTable?.unit.unitType ?? "unknown",
this.#spawnRequestTable?.coalition ?? "blue",
false,
hash
);
}

View File

@ -421,7 +421,7 @@
color: var(--secondary-blue-text);
}
[data-object|="unit"][data-coalition="blue"][data-is-selected] path {
[data-object|="unit"][data-coalition="blue"][data-is-selected] path:nth-child(1) {
fill: var(--secondary-blue-text);
}
@ -577,10 +577,41 @@
display: block;
}
.ol-temporary-marker {
.ol-temporary-marker .unit-icon {
opacity: 0.5;
}
.ol-temporary-marker .unit-short-label {
opacity: 0.5;
}
.ol-temporary-marker .heading-handle {
width: 30px;
height: 30px;
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
cursor: move;
pointer-events: all;
}
.ol-temporary-marker .heading-handle svg {
position: absolute;
width: 100%;
}
.ol-temporary-marker [data-coalition="blue"] .heading-handle svg {
fill: var(--unit-background-blue);
}
.ol-temporary-marker [data-coalition="red"] .heading-handle svg {
fill: var(--unit-background-red);
}
.ol-temporary-marker [data-coalition="neutral"] .heading-handle svg {
fill: var(--unit-background-neutral);
}
.unit-bullseye,
.unit-braa {
width: 50%;

View File

@ -3,6 +3,8 @@ import { DivIcon, LatLng } from "leaflet";
import { SVGInjector } from "@tanem/svg-injector";
import { getApp } from "../../olympusapp";
import { UnitBlueprint } from "../../interfaces";
import { deg2rad, normalizeAngle, rad2deg } from "../../other/utils";
import { SpawnHeadingChangedEvent } from "../../events";
export class TemporaryUnitMarker extends CustomMarker {
#name: string;
@ -72,6 +74,50 @@ export class TemporaryUnitMarker extends CustomMarker {
el.append(shortLabel);
}
// Heading handle
if (this.#headingHandle) {
var handle = document.createElement("div");
var handleImg = document.createElement("img");
handleImg.src = "/images/others/arrow.svg";
handleImg.onload = () => SVGInjector(handleImg);
handle.classList.add("heading-handle");
el.append(handle);
handle.append(handleImg);
const rotateHandle = (heading) => {
el.style.transform = `rotate(${heading}deg)`;
unitIcon.style.transform = `rotate(-${heading}deg)`;
shortLabel.style.transform = `rotate(-${heading}deg)`;
};
SpawnHeadingChangedEvent.on((heading) => rotateHandle(heading));
rotateHandle(getApp().getMap().getSpawnHeading());
// Add drag and rotate functionality
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
const onMouseMove = (e) => {
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let angle = rad2deg(Math.atan2(e.clientY - centerY, e.clientX - centerX)) + 90;
angle = normalizeAngle(angle);
getApp().getMap().setSpawnHeading(angle);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
this.getElement()?.appendChild(el);
this.getElement()?.classList.add("ol-temporary-marker");
}

View File

@ -600,4 +600,18 @@ export function computeBrightness(color) {
let brightness = 0.299 * r + 0.587 * g + 0.114 * b;
return brightness;
}
/**
* Normalizes an angle to be within the range of 0 to 360 degrees.
* @param {number} angle - The angle to normalize.
* @returns {number} - The normalized angle.
*/
export function normalizeAngle(angle: number): number {
// Ensure the angle is within the range of 0 to 360 degrees
angle = angle % 360;
if (angle < 0) {
angle += 360;
}
return angle;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { BLUE_COMMANDER, colors, COMMAND_MODE_OPTIONS_DEFAULTS, GAME_MASTER, NO_SUBSTATE, OlympusState, OlympusSubState } from "../../constants/constants";
import { LatLng } from "leaflet";
import {
@ -10,7 +10,7 @@ import {
} from "../../events";
import { getApp } from "../../olympusapp";
import { SpawnRequestTable, UnitBlueprint } from "../../interfaces";
import { faArrowLeft, faEllipsisVertical, faExplosion, faListDots, faSearch, faSmog, faStar } from "@fortawesome/free-solid-svg-icons";
import { faEllipsisVertical, faExplosion, faSearch, faSmog, faStar } from "@fortawesome/free-solid-svg-icons";
import { EffectSpawnMenu } from "../panels/effectspawnmenu";
import { UnitSpawnMenu } from "../panels/unitspawnmenu";
import { OlEffectListEntry } from "../components/oleffectlistentry";
@ -28,6 +28,7 @@ import { OlDropdownItem } from "../components/oldropdown";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OlCoalitionToggle } from "../components/olcoalitiontoggle";
import { Coalition } from "../../types/types";
import { spawn } from "child_process";
enum CategoryGroup {
NONE,
@ -62,6 +63,7 @@ export function SpawnContextMenu(props: {}) {
const [spawnCoalition, setSpawnCoalition] = useState("blue" as Coalition);
const [showMore, setShowMore] = useState(false);
const [height, setHeight] = useState(0);
const [translated, setTranslated] = useState(false);
useEffect(() => {
if (selectedRole) setBlueprints(getApp()?.getUnitsManager().getDatabase().getByRole(selectedRole));
@ -110,6 +112,19 @@ export function SpawnContextMenu(props: {}) {
setSelectedRole(null);
}, [openAccordion]);
const translateMenu = useCallback(() => {
if (blueprint && !translated) {
setTranslated(true);
setXPosition(xPosition + 60);
setYPosition(yPosition + 40);
} else if (!blueprint && translated) {
setTranslated(false);
setXPosition(xPosition - 60);
setYPosition(yPosition - 40);
}
}, [blueprint, translated])
useEffect(translateMenu, [blueprint, translated])
/* Filter the blueprints according to the label */
const filteredBlueprints: UnitBlueprint[] = [];
if (blueprints && filterString !== "") {
@ -131,6 +146,7 @@ export function SpawnContextMenu(props: {}) {
const containerPoint = getApp().getMap().latLngToContainerPoint(latlng);
setXPosition(getApp().getMap().getContainer().offsetLeft + containerPoint.x);
setYPosition(getApp().getMap().getContainer().offsetTop + containerPoint.y);
setTranslated(false);
});
}, []);

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Menu } from "./components/menu";
import { getApp } from "../../olympusapp";
import { FaPlus, FaPlusCircle, FaQuestionCircle } from "react-icons/fa";
import { FaPlus, FaQuestionCircle } from "react-icons/fa";
import { AudioSourcePanel } from "./components/sourcepanel";
import { AudioSource } from "../../audio/audiosource";
import { RadioSinkPanel } from "./components/radiosinkpanel";

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { OlUnitSummary } from "../components/olunitsummary";
import { OlCoalitionToggle } from "../components/olcoalitiontoggle";
import { OlNumberInput } from "../components/olnumberinput";
@ -9,7 +9,7 @@ import { LoadoutBlueprint, SpawnRequestTable, UnitBlueprint } from "../../interf
import { OlStateButton } from "../components/olstatebutton";
import { Coalition } from "../../types/types";
import { getApp } from "../../olympusapp";
import { deepCopyTable, ftToM, hash, mode } from "../../other/utils";
import { deepCopyTable, deg2rad, ftToM, hash, mode, normalizeAngle } from "../../other/utils";
import { LatLng } from "leaflet";
import { Airbase } from "../../mission/airbase";
import { altitudeIncrements, groupUnitCount, maxAltitudeValues, minAltitudeValues, OlympusState, SpawnSubState } from "../../constants/constants";
@ -17,8 +17,9 @@ import { faArrowLeft, faStar } from "@fortawesome/free-solid-svg-icons";
import { OlStringInput } from "../components/olstringinput";
import { countryCodes } from "../data/codes";
import { OlAccordion } from "../components/olaccordion";
import { AppStateChangedEvent } from "../../events";
import { AppStateChangedEvent, SpawnHeadingChangedEvent } from "../../events";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FaQuestionCircle } from "react-icons/fa";
export function UnitSpawnMenu(props: {
visible: boolean;
@ -123,6 +124,46 @@ export function UnitSpawnMenu(props: {
if (props.coalition) setSpawnCoalition(props.coalition);
}, [props.coalition]);
/* Heading compass */
const [compassAngle, setCompassAngle] = useState(0);
const compassRef = useRef<HTMLImageElement>(null);
const updateSpawnRequestTableHeading = useCallback(() => {
getApp()?.getMap().setSpawnHeading(compassAngle);
}, [compassAngle]);
useEffect(updateSpawnRequestTableHeading, [compassAngle]);
useEffect(() => {
SpawnHeadingChangedEvent.on((heading) => {
setCompassAngle(heading);
});
}, []);
useEffect(() => {
setCompassAngle(getApp()?.getMap().getSpawnHeading() ?? 0);
}, [appState]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const onMouseMove = (e: MouseEvent) => {
if (compassRef.current) {
const rect = compassRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
setCompassAngle(Math.round(normalizeAngle(angle + 90)));
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
/* Get a list of all the roles */
const roles: string[] = [];
props.blueprint?.loadouts?.forEach((loadout) => {
@ -198,9 +239,11 @@ export function UnitSpawnMenu(props: {
/>
<OlStateButton
onClick={() => {
if (spawnRequestTable)
if (spawnRequestTable) {
spawnRequestTable.unit.heading = compassAngle;
if (key in props.starredSpawns) getApp().getMap().removeStarredSpawnRequestTable(key);
else getApp().getMap().addStarredSpawnRequestTable(key, spawnRequestTable, quickAccessName);
}
}}
tooltip="Save this spawn for quick access"
checked={key in props.starredSpawns}
@ -355,10 +398,9 @@ export function UnitSpawnMenu(props: {
`}
>
{props.blueprint?.liveries && props.blueprint?.liveries[id].countries.length == 1 && (
<img
src={`images/countries/${country?.flagCode.toLowerCase()}.svg`}
className={`h-6`}
/>
<img src={`images/countries/${country?.flagCode.toLowerCase()}.svg`} className={`
h-6
`} />
)}
<div className="my-auto truncate">
@ -410,6 +452,48 @@ export function UnitSpawnMenu(props: {
</OlDropdown>
</div>
</div>
<div className="my-5 flex justify-between">
<div className="my-auto flex flex-col gap-2">
<span>Spawn heading</span>
<div className="flex gap-1 text-sm text-gray-400">
<FaQuestionCircle className={`my-auto`} /> <div className={`
my-auto
`}>Drag to change</div>
</div>
</div>
<OlNumberInput
className={"my-auto"}
min={0}
max={360}
onChange={(ev) => {
setCompassAngle(Number(ev.target.value));
}}
onDecrease={() => {
setCompassAngle(normalizeAngle(compassAngle - 1));
}}
onIncrease={() => {
setCompassAngle(normalizeAngle(compassAngle + 1));
}}
value={compassAngle}
/>
<div className={`relative mr-3 h-[60px] w-[60px]`}>
<img className="absolute" ref={compassRef} onMouseDown={handleMouseDown} src={"/images/others/arrow_background.png"}></img>
<img
className="absolute left-0"
ref={compassRef}
onMouseDown={handleMouseDown}
src={"/images/others/arrow.png"}
style={{
width: "60px",
height: "60px",
transform: `rotate(${compassAngle}deg)`,
cursor: "pointer",
}}
></img>
</div>
</div>
</OlAccordion>
</div>
<OlAccordion
@ -467,7 +551,8 @@ export function UnitSpawnMenu(props: {
focus:outline-none focus:ring-4
`}
onClick={() => {
if (spawnRequestTable)
if (spawnRequestTable){
spawnRequestTable.unit.heading = deg2rad(compassAngle);
getApp()
.getUnitsManager()
.spawnUnits(
@ -477,6 +562,7 @@ export function UnitSpawnMenu(props: {
false,
props.airbase?.getName() ?? undefined
);
}
getApp().setState(OlympusState.IDLE);
}}
@ -694,9 +780,10 @@ export function UnitSpawnMenu(props: {
`}
>
{props.blueprint?.liveries && props.blueprint?.liveries[id].countries.length == 1 && (
<img src={`images/countries/${country?.flagCode.toLowerCase()}.svg`} className={`
h-6
`} />
<img
src={`images/countries/${country?.flagCode.toLowerCase()}.svg`}
className={`h-6`}
/>
)}
<div className="my-auto truncate">
@ -748,6 +835,48 @@ export function UnitSpawnMenu(props: {
})}
</OlDropdown>
</div>
<div className="my-5 flex justify-between">
<div className="my-auto flex flex-col gap-2">
<span className="text-white">Spawn heading</span>
<div className="flex gap-1 text-sm text-gray-400">
<FaQuestionCircle className={`my-auto`} /> <div className={`
my-auto
`}>Drag to change</div>
</div>
</div>
<OlNumberInput
className={"my-auto"}
min={0}
max={360}
onChange={(ev) => {
setCompassAngle(Number(ev.target.value));
}}
onDecrease={() => {
setCompassAngle(normalizeAngle(compassAngle - 1));
}}
onIncrease={() => {
setCompassAngle(normalizeAngle(compassAngle + 1));
}}
value={compassAngle}
/>
<div className={`relative mr-3 h-[60px] w-[60px]`}>
<img className="absolute" ref={compassRef} onMouseDown={handleMouseDown} src={"/images/others/arrow_background.png"}></img>
<img
className="absolute left-0"
ref={compassRef}
onMouseDown={handleMouseDown}
src={"/images/others/arrow.png"}
style={{
width: "60px",
height: "60px",
transform: `rotate(${compassAngle}deg)`,
cursor: "pointer",
}}
></img>
</div>
</div>
</div>
{spawnLoadout && spawnLoadout.items.length > 0 && (
<div
@ -802,7 +931,7 @@ export function UnitSpawnMenu(props: {
hover:bg-blue-800
`}
onClick={() => {
if (spawnRequestTable)
if (spawnRequestTable) {
getApp()
.getUnitsManager()
.spawnUnits(
@ -812,6 +941,7 @@ export function UnitSpawnMenu(props: {
false,
props.airbase?.getName()
);
}
}}
>
Spawn