Completed custom weapon wizard modal

This commit is contained in:
Pax1601
2025-10-26 16:34:27 +01:00
parent 94d0b4d10e
commit b42280133c
14 changed files with 1803 additions and 1220 deletions

View File

@@ -185,6 +185,7 @@ struct SpawnOptions {
string skill; string skill;
string liveryID; string liveryID;
double heading; double heading;
string payload;
}; };
struct CloneOptions { struct CloneOptions {

View File

@@ -102,6 +102,7 @@ string SpawnAircrafts::getString()
<< "alt = " << spawnOptions[i].location.alt << ", " << "alt = " << spawnOptions[i].location.alt << ", "
<< "heading = " << spawnOptions[i].heading << ", " << "heading = " << spawnOptions[i].heading << ", "
<< "loadout = \"" << spawnOptions[i].loadout << "\"" << ", " << "loadout = \"" << spawnOptions[i].loadout << "\"" << ", "
<< "payload = " << spawnOptions[i].payload << ", "
<< "liveryID = " << "\"" << spawnOptions[i].liveryID << "\"" << ", " << "liveryID = " << "\"" << spawnOptions[i].liveryID << "\"" << ", "
<< "skill = \"" << spawnOptions[i].skill << "\"" << "}, "; << "skill = \"" << spawnOptions[i].skill << "\"" << "}, ";
} }
@@ -132,6 +133,7 @@ string SpawnHelicopters::getString()
<< "alt = " << spawnOptions[i].location.alt << ", " << "alt = " << spawnOptions[i].location.alt << ", "
<< "heading = " << spawnOptions[i].heading << ", " << "heading = " << spawnOptions[i].heading << ", "
<< "loadout = \"" << spawnOptions[i].loadout << "\"" << ", " << "loadout = \"" << spawnOptions[i].loadout << "\"" << ", "
<< "payload = " << spawnOptions[i].payload << ", "
<< "liveryID = " << "\"" << spawnOptions[i].liveryID << "\"" << ", " << "liveryID = " << "\"" << spawnOptions[i].liveryID << "\"" << ", "
<< "skill = \"" << spawnOptions[i].skill << "\"" << "}, "; << "skill = \"" << spawnOptions[i].skill << "\"" << "}, ";
} }

View File

@@ -223,7 +223,11 @@ void Scheduler::handleRequest(string key, json::value value, string username, js
string liveryID = to_string(unit[L"liveryID"]); string liveryID = to_string(unit[L"liveryID"]);
string skill = to_string(unit[L"skill"]); string skill = to_string(unit[L"skill"]);
spawnOptions.push_back({ unitType, location, loadout, skill, liveryID, heading }); string payload = "nil";
if (unit.has_string_field(L"payload"))
payload = to_string(unit[L"payload"]);
spawnOptions.push_back({ unitType, location, loadout, skill, liveryID, heading, payload });
log(username + " spawned a " + coalition + " " + unitType, true); log(username + " spawned a " + coalition + " " + unitType, true);
} }

View File

@@ -399,6 +399,7 @@ export enum SpawnSubState {
NO_SUBSTATE = "No substate", NO_SUBSTATE = "No substate",
SPAWN_UNIT = "Unit", SPAWN_UNIT = "Unit",
SPAWN_EFFECT = "Effect", SPAWN_EFFECT = "Effect",
LOADOUT_WIZARD = "Loadout wizard"
} }
export enum OptionsSubstate { export enum OptionsSubstate {

File diff suppressed because it is too large Load Diff

View File

@@ -2,403 +2,405 @@ import { LatLng } from "leaflet";
import { AudioOptions, Coalition, MapOptions } from "./types/types"; import { AudioOptions, Coalition, MapOptions } from "./types/types";
export interface OlympusConfig { export interface OlympusConfig {
/* Set by user */ /* Set by user */
frontend: { frontend: {
port: number; port: number;
elevationProvider: { elevationProvider: {
provider: string; provider: string;
username: string | null; username: string | null;
password: string | null; password: string | null;
}; };
mapLayers: { mapLayers: {
[key: string]: { [key: string]: {
urlTemplate: string; urlTemplate: string;
minZoom: number; minZoom: number;
maxZoom: number; maxZoom: number;
attribution?: string; attribution?: string;
}; };
}; };
mapMirrors: { mapMirrors: {
[key: string]: string; [key: string]: string;
};
/* New with v2.0.0 */
customAuthHeaders?: {
enabled: boolean;
username: string;
group: string;
};
autoconnectWhenLocal?: boolean;
}; };
/* New with v2.0.0 */ /* New with v2.0.0 */
customAuthHeaders?: { audio?: {
enabled: boolean; SRSPort: number;
username: string; WSPort?: number;
group: string; WSEndpoint?: string;
}; };
autoconnectWhenLocal?: boolean; controllers?: [{ type: string; coalition: Coalition; frequency: number; modulation: number; callsign: string }];
}; profiles?: { [key: string]: ProfileOptions };
/* New with v2.0.0 */
audio?: {
SRSPort: number;
WSPort?: number;
WSEndpoint?: string;
};
controllers?: [{ type: string; coalition: Coalition; frequency: number; modulation: number; callsign: string }];
profiles?: { [key: string]: ProfileOptions };
/* Set by server */ /* Set by server */
local?: boolean; local?: boolean;
authentication?: { authentication?: {
// Only sent when in localhost mode for autologin // Only sent when in localhost mode for autologin
gameMasterPassword: string; gameMasterPassword: string;
blueCommanderPassword: string; blueCommanderPassword: string;
redCommanderPassword: string; redCommanderPassword: string;
}; };
} }
export interface SessionData { export interface SessionData {
radios?: { frequency: number; modulation: number; pan: number }[]; radios?: { frequency: number; modulation: number; pan: number }[];
fileSources?: { filename: string; volume: number }[]; fileSources?: { filename: string; volume: number }[];
unitSinks?: { ID: number }[]; unitSinks?: { ID: number }[];
connections?: any[]; connections?: any[];
coalitionAreas?: ( coalitionAreas?: (
| { type: "circle"; label: string; latlng: { lat: number; lng: number }; radius: number; coalition: Coalition } | { type: "circle"; label: string; latlng: { lat: number; lng: number }; radius: number; coalition: Coalition }
| { type: "polygon"; label: string; latlngs: { lat: number; lng: number }[]; coalition: Coalition } | { type: "polygon"; label: string; latlngs: { lat: number; lng: number }[]; coalition: Coalition }
)[]; )[];
hotgroups?: { [key: string]: number[] }; hotgroups?: { [key: string]: number[] };
starredSpawns?: { [key: number]: SpawnRequestTable }; starredSpawns?: { [key: number]: SpawnRequestTable };
drawings?: { [key: string]: { visibility: boolean; opacity: number; name: string; guid: string; containers: any; drawings: any } }; drawings?: { [key: string]: { visibility: boolean; opacity: number; name: string; guid: string; containers: any; drawings: any } };
navpoints?: { [key: string]: { visibility: boolean; opacity: number; name: string; guid: string; containers: any; drawings: any } }; navpoints?: { [key: string]: { visibility: boolean; opacity: number; name: string; guid: string; containers: any; drawings: any } };
mapSource?: { id: string }; mapSource?: { id: string };
customLoadouts?: { [key: string]: LoadoutBlueprint[] };
} }
export interface ProfileOptions { export interface ProfileOptions {
mapOptions?: MapOptions; mapOptions?: MapOptions;
shortcuts?: { [key: string]: ShortcutOptions }; shortcuts?: { [key: string]: ShortcutOptions };
audioOptions?: AudioOptions; audioOptions?: AudioOptions;
} }
export interface ContextMenuOption { export interface ContextMenuOption {
tooltip: string; tooltip: string;
src: string; src: string;
callback: CallableFunction; callback: CallableFunction;
} }
export interface AirbasesData { export interface AirbasesData {
airbases: { [key: string]: any }; airbases: { [key: string]: any };
sessionHash: string; sessionHash: string;
time: number; time: number;
} }
export interface BullseyesData { export interface BullseyesData {
bullseyes: { bullseyes: {
[key: string]: { latitude: number; longitude: number; coalition: string }; [key: string]: { latitude: number; longitude: number; coalition: string };
}; };
sessionHash: string; sessionHash: string;
time: number; time: number;
} }
export interface SpotsData { export interface SpotsData {
spots: { spots: {
[key: string]: { active: boolean; type: string; targetPosition: { lat: number; lng: number }; sourceUnitID: number; code?: number }; [key: string]: { active: boolean; type: string; targetPosition: { lat: number; lng: number }; sourceUnitID: number; code?: number };
}; };
sessionHash: string; sessionHash: string;
time: number; time: number;
} }
export interface MissionData { export interface MissionData {
mission: { mission: {
theatre: string; theatre: string;
dateAndTime: DateAndTime; dateAndTime: DateAndTime;
commandModeOptions: CommandModeOptions; commandModeOptions: CommandModeOptions;
coalitions: { red: string[]; blue: string[] }; coalitions: { red: string[]; blue: string[] };
}; };
time: number; time: number;
sessionHash: string; sessionHash: string;
} }
export interface CommandModeOptions { export interface CommandModeOptions {
commandMode: string; commandMode: string;
restrictSpawns: boolean; restrictSpawns: boolean;
restrictToCoalition: boolean; restrictToCoalition: boolean;
setupTime: number; setupTime: number;
spawnPoints: { spawnPoints: {
red: number; red: number;
blue: number; blue: number;
}; };
eras: string[]; eras: string[];
} }
export interface DateAndTime { export interface DateAndTime {
date: { Year: number; Month: number; Day: number }; date: { Year: number; Month: number; Day: number };
time: { h: number; m: number; s: number }; time: { h: number; m: number; s: number };
elapsedTime: number; elapsedTime: number;
startTime: number; startTime: number;
} }
export interface LogData { export interface LogData {
logs: { [key: string]: string }; logs: { [key: string]: string };
sessionHash: string; sessionHash: string;
time: number; time: number;
} }
export interface ServerRequestOptions { export interface ServerRequestOptions {
time?: number; time?: number;
commandHash?: string; commandHash?: string;
} }
export interface SpawnRequestTable { export interface SpawnRequestTable {
category: string; category: string;
coalition: string; coalition: string;
unit: UnitSpawnTable; unit: UnitSpawnTable;
amount: number; amount: number;
quickAccessName?: string; quickAccessName?: string;
} }
export interface EffectRequestTable { export interface EffectRequestTable {
type: string; type: string;
explosionType?: string; explosionType?: string;
smokeColor?: string; smokeColor?: string;
} }
export interface UnitSpawnTable { export interface UnitSpawnTable {
unitType: string; unitType: string;
location: LatLng; location: LatLng;
skill: string; skill: string;
liveryID: string; liveryID: string;
altitude?: number; altitude?: number;
loadout?: string; loadout?: string;
heading?: number; heading?: number;
payload?: string;
} }
export interface ObjectIconOptions { export interface ObjectIconOptions {
showState: boolean; showState: boolean;
showVvi: boolean; showVvi: boolean;
showHealth: boolean; showHealth: boolean;
showHotgroup: boolean; showHotgroup: boolean;
showUnitIcon: boolean; showUnitIcon: boolean;
showShortLabel: boolean; showShortLabel: boolean;
showFuel: boolean; showFuel: boolean;
showAmmo: boolean; showAmmo: boolean;
showSummary: boolean; showSummary: boolean;
showCallsign: boolean; showCallsign: boolean;
rotateToHeading: boolean; rotateToHeading: boolean;
showCluster: boolean; showCluster: boolean;
showAlarmState: boolean; showAlarmState: boolean;
} }
export interface GeneralSettings { export interface GeneralSettings {
prohibitJettison: boolean; prohibitJettison: boolean;
prohibitAA: boolean; prohibitAA: boolean;
prohibitAG: boolean; prohibitAG: boolean;
prohibitAfterburner: boolean; prohibitAfterburner: boolean;
prohibitAirWpn: boolean; prohibitAirWpn: boolean;
} }
export interface TACAN { export interface TACAN {
isOn: boolean; isOn: boolean;
channel: number; channel: number;
XY: string; XY: string;
callsign: string; callsign: string;
} }
export interface Radio { export interface Radio {
frequency: number; frequency: number;
callsign: number; callsign: number;
callsignNumber: number; callsignNumber: number;
} }
export interface Ammo { export interface Ammo {
quantity: number; quantity: number;
name: string; name: string;
guidance: number; guidance: number;
category: number; category: number;
missileCategory: number; missileCategory: number;
} }
export interface Contact { export interface Contact {
ID: number; ID: number;
detectionMethod: number; detectionMethod: number;
} }
export interface Offset { export interface Offset {
x: number; x: number;
y: number; y: number;
z: number; z: number;
} }
export interface DrawingArgument { export interface DrawingArgument {
argument: number; argument: number;
value: number; value: number;
} }
export interface UnitData { export interface UnitData {
category: string; category: string;
markerCategory: string; markerCategory: string;
ID: number; ID: number;
alive: boolean; alive: boolean;
alarmState: AlarmState; alarmState: AlarmState;
human: boolean; human: boolean;
controlled: boolean; controlled: boolean;
coalition: string; coalition: string;
country: number; country: number;
name: string; name: string;
unitName: string; unitName: string;
callsign: string; callsign: string;
unitID: number; unitID: number;
groupID: number; groupID: number;
groupName: string; groupName: string;
state: string; state: string;
task: string; task: string;
hasTask: boolean; hasTask: boolean;
position: LatLng; position: LatLng;
speed: number; speed: number;
horizontalVelocity: number; horizontalVelocity: number;
verticalVelocity: number; verticalVelocity: number;
heading: number; heading: number;
track: number; track: number;
isActiveTanker: boolean; isActiveTanker: boolean;
isActiveAWACS: boolean; isActiveAWACS: boolean;
onOff: boolean; onOff: boolean;
followRoads: boolean; followRoads: boolean;
fuel: number; fuel: number;
desiredSpeed: number; desiredSpeed: number;
desiredSpeedType: string; desiredSpeedType: string;
desiredAltitude: number; desiredAltitude: number;
desiredAltitudeType: string; desiredAltitudeType: string;
leaderID: number; leaderID: number;
formationOffset: Offset; formationOffset: Offset;
targetID: number; targetID: number;
targetPosition: LatLng; targetPosition: LatLng;
ROE: string; ROE: string;
reactionToThreat: string; reactionToThreat: string;
emissionsCountermeasures: string; emissionsCountermeasures: string;
TACAN: TACAN; TACAN: TACAN;
radio: Radio; radio: Radio;
generalSettings: GeneralSettings; generalSettings: GeneralSettings;
ammo: Ammo[]; ammo: Ammo[];
contacts: Contact[]; contacts: Contact[];
activePath: LatLng[]; activePath: LatLng[];
isLeader: boolean; isLeader: boolean;
operateAs: string; operateAs: string;
shotsScatter: number; shotsScatter: number;
shotsIntensity: number; shotsIntensity: number;
health: number; health: number;
racetrackLength: number; racetrackLength: number;
racetrackAnchor: LatLng; racetrackAnchor: LatLng;
racetrackBearing: number; racetrackBearing: number;
timeToNextTasking: number; timeToNextTasking: number;
barrelHeight: number; barrelHeight: number;
muzzleVelocity: number; muzzleVelocity: number;
aimTime: number; aimTime: number;
shotsToFire: number; shotsToFire: number;
shotsBaseInterval: number; shotsBaseInterval: number;
shotsBaseScatter: number; shotsBaseScatter: number;
engagementRange: number; engagementRange: number;
targetingRange: number; targetingRange: number;
aimMethodRange: number; aimMethodRange: number;
acquisitionRange: number; acquisitionRange: number;
airborne: boolean; airborne: boolean;
cargoWeight: number; cargoWeight: number;
drawingArguments: DrawingArgument[]; drawingArguments: DrawingArgument[];
customString: string; customString: string;
customInteger: number; customInteger: number;
} }
export interface LoadoutItemBlueprint { export interface LoadoutItemBlueprint {
name: string; name: string;
quantity: number; quantity: number;
type: string;
effectiveAgainst?: string;
} }
export interface LoadoutBlueprint { export interface LoadoutBlueprint {
fuel: number; items: LoadoutItemBlueprint[];
items: LoadoutItemBlueprint[]; roles: string[];
roles: string[]; code: string;
code: string; name: string;
name: string; enabled: boolean;
enabled: boolean; isCustom?: boolean;
persistent?: boolean;
payload?: string;
} }
export interface UnitBlueprint { export interface UnitBlueprint {
name: string; name: string;
category: string; category: string;
enabled: boolean; enabled: boolean;
coalition: string; coalition: string;
era: string; era: string;
label: string; label: string;
shortLabel: string; shortLabel: string;
roles?: string[]; roles?: string[];
type?: string; type?: string;
loadouts?: LoadoutBlueprint[]; loadouts?: LoadoutBlueprint[];
acceptedPayloads?: { [key: string]: { clsids: string; name: string, weight: number }[] }; acceptedPayloads?: { [key: string]: { clsid: string; name: string; weight: number }[] };
filename?: string; filename?: string;
liveries?: { [key: string]: { name: string; countries: string[] } }; liveries?: { [key: string]: { name: string; countries: string[] } };
cost?: number; cost?: number;
barrelHeight?: number; barrelHeight?: number;
muzzleVelocity?: number; muzzleVelocity?: number;
aimTime?: number; aimTime?: number;
shotsToFire?: number; shotsToFire?: number;
shotsBaseInterval?: number; shotsBaseInterval?: number;
shotsBaseScatter?: number; shotsBaseScatter?: number;
description?: string; description?: string;
abilities?: string; abilities?: string;
tags?: string; tags?: string;
acquisitionRange?: number; acquisitionRange?: number;
engagementRange?: number; engagementRange?: number;
targetingRange?: number; targetingRange?: number;
aimMethodRange?: number; aimMethodRange?: number;
alertnessTimeConstant?: number; alertnessTimeConstant?: number;
canTargetPoint?: boolean; canTargetPoint?: boolean;
canRearm?: boolean; canRearm?: boolean;
canAAA?: boolean; canAAA?: boolean;
indirectFire?: boolean; indirectFire?: boolean;
markerFile?: string; markerFile?: string;
unitWhenGrouped?: string; unitWhenGrouped?: string;
mainRole?: string; mainRole?: string;
length?: number; length?: number;
carrierFilename?: string; carrierFilename?: string;
} }
export interface AirbaseOptions { export interface AirbaseOptions {
name: string; name: string;
position: L.LatLng; position: L.LatLng;
} }
export interface AirbaseChartData { export interface AirbaseChartData {
elevation: string; elevation: string;
ICAO: string; ICAO: string;
TACAN: string; TACAN: string;
runways: AirbaseChartRunwayData[]; runways: AirbaseChartRunwayData[];
} }
export interface AirbaseChartRunwayHeadingData { export interface AirbaseChartRunwayHeadingData {
[index: string]: { [index: string]: {
magHeading: string; magHeading: string;
ILS: string; ILS: string;
}; };
} }
export interface AirbaseChartRunwayData { export interface AirbaseChartRunwayData {
headings: AirbaseChartRunwayHeadingData[]; headings: AirbaseChartRunwayHeadingData[];
length: string; length: string;
} }
export interface ShortcutOptions { export interface ShortcutOptions {
label: string; label: string;
keyUpCallback: (e: KeyboardEvent) => void; keyUpCallback: (e: KeyboardEvent) => void;
keyDownCallback?: (e: KeyboardEvent) => void; keyDownCallback?: (e: KeyboardEvent) => void;
code: string; code: string;
altKey?: boolean; altKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
shiftKey?: boolean; shiftKey?: boolean;
} }
export interface ServerStatus { export interface ServerStatus {
frameRate: number; frameRate: number;
load: number; load: number;
elapsedTime: number; elapsedTime: number;
missionTime: DateAndTime["time"]; missionTime: DateAndTime["time"];
connected: boolean; connected: boolean;
paused: boolean; paused: boolean;
} }
export type DrawingPoint = { export type DrawingPoint = {
x: number; x: number;
y: number; y: number;
}; };
export type PolygonPoints = DrawingPoint[] | DrawingPoint; export type PolygonPoints = DrawingPoint[] | DrawingPoint;
@@ -406,36 +408,36 @@ export type PolygonPoints = DrawingPoint[] | DrawingPoint;
export type DrawingPrimitiveType = "TextBox" | "Polygon" | "Line" | "Icon"; export type DrawingPrimitiveType = "TextBox" | "Polygon" | "Line" | "Icon";
export interface Drawing { export interface Drawing {
name: string; name: string;
visible: boolean; visible: boolean;
mapX: number; mapX: number;
mapY: number; mapY: number;
layerName: string; layerName: string;
layer: string; layer: string;
primitiveType: DrawingPrimitiveType; primitiveType: DrawingPrimitiveType;
colorString: string; colorString: string;
fillColorString?: string; fillColorString?: string;
borderThickness?: number; borderThickness?: number;
fontSize?: number; fontSize?: number;
font?: string; font?: string;
text?: string; text?: string;
angle?: number; angle?: number;
radius?: number; radius?: number;
points?: PolygonPoints; points?: PolygonPoints;
style?: string; style?: string;
polygonMode?: string; polygonMode?: string;
thickness?: number; thickness?: number;
width?: number; width?: number;
height?: number; height?: number;
closed?: boolean; closed?: boolean;
lineMode?: string; lineMode?: string;
hiddenOnPlanner?: boolean; hiddenOnPlanner?: boolean;
file?: string; file?: string;
scale?: number; scale?: number;
} }
export enum AlarmState { export enum AlarmState {
RED = 'red', RED = "red",
GREEN = 'green', GREEN = "green",
AUTO = 'auto' AUTO = "auto",
} }

View File

@@ -310,11 +310,13 @@ export class OlympusApp {
} }
setState(state: OlympusState, subState: OlympusSubState = NO_SUBSTATE) { setState(state: OlympusState, subState: OlympusSubState = NO_SUBSTATE) {
const previousState = this.#state;
const previousSubState = this.#subState;
this.#state = state; this.#state = state;
this.#subState = subState; this.#subState = subState;
console.log(`App state set to ${state}, substate ${subState}`); console.log(`App state set to ${state}, substate ${subState}`);
AppStateChangedEvent.dispatch(state, subState); AppStateChangedEvent.dispatch(state, subState, previousState, previousSubState);
} }
getState() { getState() {

View File

@@ -8,6 +8,7 @@ import {
AudioSinksChangedEvent, AudioSinksChangedEvent,
AudioSourcesChangedEvent, AudioSourcesChangedEvent,
CoalitionAreasChangedEvent, CoalitionAreasChangedEvent,
CustomLoadoutsUpdatedEvent,
DrawingsUpdatedEvent, DrawingsUpdatedEvent,
HotgroupsChangedEvent, HotgroupsChangedEvent,
MapSourceChangedEvent, MapSourceChangedEvent,
@@ -16,7 +17,7 @@ import {
SessionDataSavedEvent, SessionDataSavedEvent,
StarredSpawnsChangedEvent, StarredSpawnsChangedEvent,
} from "./events"; } from "./events";
import { SessionData } from "./interfaces"; import { LoadoutBlueprint, SessionData } from "./interfaces";
import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle"; import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle";
import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon"; import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon";
import { getApp } from "./olympusapp"; import { getApp } from "./olympusapp";
@@ -124,7 +125,7 @@ export class SessionDataManager {
HotgroupsChangedEvent.on((hotgroups) => { HotgroupsChangedEvent.on((hotgroups) => {
this.#sessionData.hotgroups = {}; this.#sessionData.hotgroups = {};
Object.keys(hotgroups).forEach((hotgroup) => { Object.keys(hotgroups).forEach((hotgroup) => {
(this.#sessionData.hotgroups as { [key: string]: number[] })[hotgroup] = hotgroups[hotgroup].map((unit) => unit.ID); (this.#sessionData.hotgroups as { [key: string]: number[] })[hotgroup] = hotgroups[parseInt(hotgroup)].map((unit) => unit.ID);
}); });
this.#saveSessionData(); this.#saveSessionData();
}); });
@@ -146,6 +147,16 @@ export class SessionDataManager {
this.#sessionData.mapSource = { id: source }; this.#sessionData.mapSource = { id: source };
this.#saveSessionData(); this.#saveSessionData();
}); });
CustomLoadoutsUpdatedEvent.on((unitName, loadout) => {
// If the loadout is of type isPersistent, update the session data
if (loadout.persistent) {
if (!this.#sessionData.customLoadouts) this.#sessionData.customLoadouts = {};
if (!this.#sessionData.customLoadouts[unitName]) this.#sessionData.customLoadouts[unitName] = [];
this.#sessionData.customLoadouts[unitName].push({...loadout});
}
this.#saveSessionData();
});
}, 200); }, 200);
}); });
} }

View File

@@ -8,8 +8,9 @@ export function Modal(props: {
open: boolean; open: boolean;
children?: JSX.Element | JSX.Element[]; children?: JSX.Element | JSX.Element[];
className?: string; className?: string;
size?: "sm" | "md" | "lg" | "full"; size?: "sm" | "md" | "lg" | "full" | "tall";
disableClose?: boolean; disableClose?: boolean;
onClose?: () => void;
}) { }) {
const [splash, setSplash] = useState(Math.ceil(Math.random() * 7)); const [splash, setSplash] = useState(Math.ceil(Math.random() * 7));
@@ -54,6 +55,14 @@ export function Modal(props: {
` `
: "" : ""
} }
${
props.size === "tall"
? `
h-[80%] w-[800px]
max-md:h-full max-md:w-full
`
: ""
}
${props.size === "full" ? "h-full w-full" : ""} ${props.size === "full" ? "h-full w-full" : ""}
`} `}
> >
@@ -90,7 +99,7 @@ export function Modal(props: {
> >
<FaXmark <FaXmark
onClick={() => { onClick={() => {
getApp().setState(OlympusState.IDLE); props.onClose ? props.onClose() : getApp().setState(OlympusState.IDLE);
}} }}
/>{" "} />{" "}
</div> </div>

View File

@@ -0,0 +1,160 @@
import React, { useEffect, useState } from "react";
import { Modal } from "./components/modal";
import { FaMagic, FaStar } from "react-icons/fa";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { getApp } from "../../olympusapp";
import { NO_SUBSTATE, OlympusState } from "../../constants/constants";
import { AppStateChangedEvent, CustomLoadoutsUpdatedEvent, SetLoadoutWizardBlueprintEvent } from "../../events";
import { WeaponsWizard } from "../panels/components/weaponswizard";
import { LoadoutBlueprint, LoadoutItemBlueprint, UnitBlueprint } from "../../interfaces";
import { OlToggle } from "../components/oltoggle";
export function LoadoutWizardModal(props: { open: boolean }) {
const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED);
const [appSubState, setAppSubState] = useState(NO_SUBSTATE);
const [previousState, setPreviousState] = useState(OlympusState.NOT_INITIALIZED);
const [previousSubState, setPreviousSubState] = useState(NO_SUBSTATE);
const [blueprint, setBlueprint] = useState(null as UnitBlueprint | null);
const [isPersistent, setIsPersistent] = useState(false);
useEffect(() => {
AppStateChangedEvent.on((appState, appSubState, previousState, previousSubState) => {
setAppState(appState);
setAppSubState(appSubState);
setPreviousState(previousState);
setPreviousSubState(previousSubState);
});
SetLoadoutWizardBlueprintEvent.on((blueprint) => {
setBlueprint(blueprint);
});
}, []);
useEffect(() => {
// Clear blueprint when modal is closed
if (!props.open) {
setBlueprint(null);
}
}, [props.open]);
const [selectedWeapons, setSelectedWeapons] = useState({} as { [key: string]: { clsid: string; name: string; weight: number } });
const [loadoutName, setLoadoutName] = useState("New loadout");
const [loadoutRole, setLoadoutRole] = useState("Custom");
useEffect(() => {
setSelectedWeapons({});
}, [props.open]);
// If "New Loadout" already exists in the blueprint loadouts, append a number to make it unique
useEffect(() => {
if (!blueprint) return;
let name = "New loadout";
let counter = 1;
const existingLoadoutNames = blueprint.loadouts?.map((loadout) => loadout.name) || [];
while (existingLoadoutNames.includes(name)) {
name = `New loadout ${counter}`;
counter++;
}
setLoadoutName(name);
}, [blueprint]);
return (
<Modal open={props.open} size={"tall"} onClose={() => getApp().setState(previousState, previousSubState)}>
<div className="flex gap-4 text-xl text-white">
<FaMagic
className={`
my-auto text-4xl text-gray-300
`}
/>
<div className="my-auto">Loadout wizard</div>
</div>
<WeaponsWizard
selectedWeapons={selectedWeapons}
setSelectedWeapons={setSelectedWeapons}
weaponsByPylon={blueprint?.acceptedPayloads ?? {}}
loadoutName={loadoutName}
setLoadoutName={setLoadoutName}
loadoutRole={loadoutRole}
setLoadoutRole={setLoadoutRole}
/>
<div className="mt-auto flex justify-between">
<div className="flex gap-2 text-gray-200">
<FaStar className={`my-auto text-2xl text-gray-200`}/>
<div className={`my-auto mr-auto`}>Keep for the rest of the session</div>
<OlToggle toggled={isPersistent} onClick={() => setIsPersistent(!isPersistent)} />
</div>
<button
type="button"
onClick={() => {
// Add a new loadout to the blueprint if it doesn't exist already
if (blueprint) {
const items: LoadoutItemBlueprint[] = [];
for (const pylon in selectedWeapons) {
const weapon = selectedWeapons[pylon];
items.push({
name: weapon.name,
quantity: 1,
});
}
// Group the weapon items and sum their quantities if there are duplicates
const groupedItems: LoadoutItemBlueprint[] = [];
const itemMap: { [key: string]: LoadoutItemBlueprint } = {};
for (const item of items) {
if (itemMap[item.name]) {
itemMap[item.name].quantity += item.quantity;
} else {
itemMap[item.name] = { ...item };
}
}
for (const itemName in itemMap) {
groupedItems.push(itemMap[itemName]);
}
// Assemble the loadout payload section as a stringified lua table containing the payload number as key and the clsid as values
// This must already be lua compatible
let payloadLuaTable = "{pylons = {";
for (const pylon in selectedWeapons) {
const weapon = selectedWeapons[pylon];
if (weapon) payloadLuaTable += `[${pylon}] = {CLSID = "${weapon.clsid}"},`;
}
payloadLuaTable += "}, fuel = 999999, flare=60, chaff=60, gun=100, ammo_type = 1}";
const newLoadout: LoadoutBlueprint = {
items: groupedItems,
roles: [loadoutRole],
code: "",
name: loadoutName,
enabled: true,
isCustom: true,
persistent: isPersistent,
payload: payloadLuaTable,
};
if (!blueprint.loadouts) {
blueprint.loadouts = [];
}
blueprint.loadouts.push(newLoadout);
CustomLoadoutsUpdatedEvent.dispatch(blueprint.name, newLoadout);
}
getApp().setState(previousState, previousSubState);
}}
className={`
mb-2 me-2 flex content-center items-center gap-2
rounded-sm bg-blue-700 px-5 py-2.5 text-sm 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
`}
>
Continue
<FontAwesomeIcon className={`my-auto`} icon={faArrowRight} />
</button>
</div>
</Modal>
);
}

View File

@@ -1,20 +1,38 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { OlDropdown, OlDropdownItem } from "../../components/oldropdown";
import { FaArrowsRotate, FaTrash, FaXmark } from "react-icons/fa6"; import { FaArrowsRotate, FaTrash, FaXmark } from "react-icons/fa6";
import { OlSearchBar } from "../../components/olsearchbar"; import { OlSearchBar } from "../../components/olsearchbar";
import { OlCheckbox } from "../../components/olcheckbox";
import { OlToggle } from "../../components/oltoggle"; import { OlToggle } from "../../components/oltoggle";
export function WeaponsWizard(props: { export function WeaponsWizard(props: {
selectedWeapons: { [key: string]: { clsids: string; name: string; weight: number } }; selectedWeapons: { [key: string]: { clsid: string; name: string; weight: number } };
setSelectedWeapons: (weapons: { [key: string]: { clsids: string; name: string; weight: number } }) => void; setSelectedWeapons: (weapons: { [key: string]: { clsid: string; name: string; weight: number } }) => void;
weaponsByPylon: { [key: string]: { clsids: string; name: string; weight: number }[] }; weaponsByPylon: { [key: string]: { clsid: string; name: string; weight: number }[] };
loadoutName: string;
setLoadoutName: (name: string) => void;
loadoutRole: string;
setLoadoutRole: (role: string) => void;
}) { }) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [selectedPylons, setSelectedPylons] = useState<string[]>([]); const [selectedPylons, setSelectedPylons] = useState<string[]>([]);
const [autofillPylons, setAutofillPylons] = useState(false); const [autofillPylons, setAutofillPylons] = useState(false);
const [fillEmptyOnly, setFillEmptyOnly] = useState(true);
const [weaponLetters, setWeaponLetters] = useState<{ [key: string]: string }>({}); // Letter to weapon name mapping
const [hoveredWeapon, setHoveredWeapon] = useState<string>("");
useEffect(() => {
// If autofill is enabled, clear selected pylons
if (autofillPylons) {
setSelectedPylons([]);
}
}, [autofillPylons]);
useEffect(() => {
// Clear search text when weaponsByPylon changes
setSearchText("");
setSelectedPylons([]);
}, [props.weaponsByPylon]);
// Find the weapons that are availabile in all the selected pylons, meaning the intersection of the weapons in each pylon // Find the weapons that are availabile in all the selected pylons, meaning the intersection of the weapons in each pylon
let availableWeapons: { clsids: string; name: string; weight: number }[] = []; let availableWeapons: { clsid: string; name: string; weight: number }[] = [];
if (autofillPylons) { if (autofillPylons) {
// If autofill is enabled, show all weapons // If autofill is enabled, show all weapons
availableWeapons = Object.values(props.weaponsByPylon).flat(); availableWeapons = Object.values(props.weaponsByPylon).flat();
@@ -39,91 +57,266 @@ export function WeaponsWizard(props: {
availableWeapons = availableWeapons.filter((weapon) => weapon.name.toLowerCase().includes(searchText.toLowerCase())); availableWeapons = availableWeapons.filter((weapon) => weapon.name.toLowerCase().includes(searchText.toLowerCase()));
} }
// If autofill is enabled and fillEmptyOnly is enabled, remove weapons that have no compatible empty pylons
if (autofillPylons && fillEmptyOnly) {
availableWeapons = availableWeapons.filter((weapon) => {
// Check if there is at least one pylon that is compatible with this weapon and is empty
return Object.keys(props.weaponsByPylon).some((pylon) => {
const weaponsInPylon = props.weaponsByPylon[pylon];
return weaponsInPylon.some((w) => w.name === weapon.name) && !props.selectedWeapons[pylon];
});
});
}
// Assign a letter to each indiviual type of weapon selected in selectedWeapons for display in the pylon selection
// Find the first unused letter
Object.values(props.selectedWeapons).forEach((weapon) => {
if (Object.entries(weaponLetters).findIndex(([letter, name]) => name === weapon.name) === -1) {
// Find the first unused letter starting from A
let currentLetter = "A";
while (weaponLetters[currentLetter]) {
currentLetter = String.fromCharCode(currentLetter.charCodeAt(0) + 1);
}
weaponLetters[currentLetter] = weapon.name;
currentLetter = String.fromCharCode(currentLetter.charCodeAt(0) + 1);
}
});
// Remove letters for weapons that are no longer selected
Object.entries(weaponLetters).forEach(([letter, name]) => {
if (Object.values(props.selectedWeapons).findIndex((weapon) => weapon.name === name) === -1) {
delete weaponLetters[letter];
}
});
if (JSON.stringify(weaponLetters) !== JSON.stringify(weaponLetters)) setWeaponLetters({ ...weaponLetters });
// List of very bright and distinct colors
const colors = {
A: "#FF5733",
B: "#33FF57",
C: "#3357FF",
D: "#F333FF",
E: "#33FFF5",
F: "#F5FF33",
G: "#FF33A8",
H: "#A833FF",
I: "#33FFA8",
J: "#FFA833",
K: "#33A8FF",
};
return ( return (
<div> <div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-6 text-white" onMouseEnter={() => setHoveredWeapon("")}>
<div className="flex justify-center"> <div className="flex flex-col gap-2">
{Object.keys(props.weaponsByPylon).map((pylon) => ( <div className="flex justify-between">
<div key={pylon} className={``}> <div className="my-auto font-semibold">Loadout Name</div>
<input
type="text"
value={props.loadoutName}
onChange={(e) => props.setLoadoutName(e.target.value)}
className={`
rounded-md border border-gray-300 bg-gray-800 p-2
text-sm text-white
`}
/>
</div>
<div className="flex justify-between">
<div className="my-auto font-semibold">Loadout Role</div>
<input
type="text"
value={props.loadoutRole}
onChange={(e) => props.setLoadoutRole(e.target.value)}
className={`
rounded-md border border-gray-300 bg-gray-800 p-2
text-sm text-white
`}
/>
</div>
</div>
<span className="text-gray-400">Select weapons for each pylon</span>
<div className="flex flex-col gap-2">
<div className="mx-auto flex flex-col gap-2">
{/* Draw an airplane seen from the front using only gray lines */}
<div className="flex justify-center">
<div <div
className={` className={`
flex h-20 flex-col items-center justify-center border-b-2 border- b-2 w-full border-gray-300
rounded-md border px-1
${
autofillPylons
? `
text-gray-400
`
: `
cursor-pointer
hover:bg-gray-700
`
}
${
selectedPylons.includes(pylon)
? `
border-gray-200
`
: `border-transparent`
}
`} `}
onClick={() => { ></div>
if (autofillPylons) return; <div
if (selectedPylons.includes(pylon)) { className={`
setSelectedPylons(selectedPylons.filter((p) => p !== pylon)); h-14 min-w-14 rounded-full border-2
} else { border-gray-300
setSelectedPylons([...selectedPylons, pylon]); `}
} ></div>
}} <div
> className={`
<div className={`text-center text-xs`}>{pylon}</div> border-b-2 border- b-2 w-full border-gray-300
<div `}
data-autofill={autofillPylons ? "true" : "false"} ></div>
className={`
h-3 w-0 rounded-full border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
`}
></div>
{props.selectedWeapons[pylon] ? (
<div
data-autofill={autofillPylons ? "true" : "false"}
className={`
flex h-6 w-6 items-center
justify-center rounded-full border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
`}
>
<div
data-autofill={autofillPylons ? "true" : "false"}
className={`
h-5 w-5 rounded-full
data-[autofill='false']:bg-white
data-[autofill='true']:bg-gray-400
`}
></div>
</div>
) : (
<div
data-autofill={autofillPylons ? "true" : "false"}
className={`
h-6 w-6 rounded-full border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
`}
></div>
)}
</div>
</div> </div>
))}
</div>
{/* Buttons to select/deselect all pylons, clear all weapons and remove weapons from selected pylons */} <div className="flex justify-center gap-1">
<div> {Object.keys(props.weaponsByPylon).map((pylon) => {
<div className="flex justify-center gap-2"> let weapon = props.selectedWeapons[pylon];
{selectedPylons.length > 0 && ( let letter = Object.entries(weaponLetters).find(([letter, name]) => name === weapon?.name)?.[0] || "";
<> // If the currently hovered weapon is compatible with this pylon, show "Hovered" else "Not Hovered"
let isHovered = props.weaponsByPylon[pylon].some((w) => w.name === hoveredWeapon);
return (
<div key={pylon} className={``}>
<div
className={`
flex h-20 flex-col items-center
justify-center rounded-md border
px-1
${
autofillPylons
? `text-gray-400`
: `
cursor-pointer
hover:bg-gray-700
`
}
${
selectedPylons.includes(pylon)
? `
border-gray-200
`
: `
border-transparent
`
}
`}
onClick={() => {
if (autofillPylons) return;
if (selectedPylons.includes(pylon)) {
setSelectedPylons(selectedPylons.filter((p) => p !== pylon));
} else {
setSelectedPylons([...selectedPylons, pylon]);
}
}}
>
<div className={`text-center text-xs`}>{pylon}</div>
<div
data-autofill={autofillPylons ? "true" : "false"}
className={`
h-3 w-0 border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
`}
></div>
{props.selectedWeapons[pylon] ? (
<div
data-autofill={autofillPylons ? "true" : "false"}
data-hovered={isHovered ? "true" : "false"}
className={`
flex h-6 w-6 items-center
justify-center
rounded-full border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
data-[hovered='true']:border-green-400
`}
>
{/* Show the letter of the group the weapon belongs to from weaponLetters */}
<span
className={`
text-sm font-bold
`}
style={{
color: letter in colors ? colors[letter as keyof typeof colors] : "inherit",
}}
>
{letter}
</span>
</div>
) : (
<div
data-autofill={autofillPylons ? "true" : "false"}
data-hovered={isHovered ? "true" : "false"}
className={`
h-6 w-6 rounded-full
border
data-[autofill='false']:border-white
data-[autofill='true']:border-gray-400
data-[hovered='true']:border-green-400
`}
></div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* List all the groups from weaponLetters */}
<div className="flex flex-col gap-1">
{Object.entries(weaponLetters).map(([letter, weapon]) => (
<div
key={letter}
className={`
flex items-center text-sm
`}
>
<span className="font-bold" style={{ color: letter in colors ? colors[letter as keyof typeof colors] : "inherit" }}>
{letter}:
</span>
<span className="ml-1 text-gray-400">{weapon}</span>
</div>
))}
</div>
{/* Buttons to select/deselect all pylons, clear all weapons and remove weapons from selected pylons */}
<div>
<div className="flex justify-center gap-2">
{selectedPylons.length > 0 && (
<>
<button
className={`
text-nowrap rounded-md bg-gray-700
px-2 py-1 text-sm
hover:bg-gray-600
`}
onClick={() => {
setSelectedPylons([]);
}}
>
<FaArrowsRotate className="inline" /> Reset selection
</button>
{
/* Checjk if any of the selected pylons have a weapon selected */
props.selectedWeapons && selectedPylons.some((pylon) => props.selectedWeapons[pylon] !== undefined) && (
<button
className={`
text-nowrap rounded-md
bg-gray-700 px-2 py-1 text-sm
hover:bg-gray-600
`}
onClick={() => {
// Remove weapons from selected pylons
let newSelectedWeapons = { ...props.selectedWeapons };
selectedPylons.forEach((pylon) => {
delete newSelectedWeapons[pylon];
});
props.setSelectedWeapons(newSelectedWeapons);
}}
>
<FaXmark
className={`
inline text-red-500
`}
/>{" "}
Remove
</button>
)
}
</>
)}
{props.selectedWeapons && Object.keys(props.selectedWeapons).length > 0 && (
<button <button
className={` className={`
text-nowrap rounded-md bg-gray-700 px-2 text-nowrap rounded-md bg-gray-700 px-2
@@ -131,56 +324,14 @@ export function WeaponsWizard(props: {
hover:bg-gray-600 hover:bg-gray-600
`} `}
onClick={() => { onClick={() => {
setSelectedPylons([]); // Clear all selected weapons
props.setSelectedWeapons({});
}} }}
> >
<FaArrowsRotate className="inline" /> Reset selection <FaTrash className="inline text-red-500" /> Delete all
</button> </button>
)}
{ </div>
/* Checjk if any of the selected pylons have a weapon selected */
props.selectedWeapons && selectedPylons.some((pylon) => props.selectedWeapons[pylon] !== undefined) && (
<button
className={`
text-nowrap rounded-md bg-gray-700
px-2 py-1 text-sm
hover:bg-gray-600
`}
onClick={() => {
// Remove weapons from selected pylons
let newSelectedWeapons = { ...props.selectedWeapons };
selectedPylons.forEach((pylon) => {
delete newSelectedWeapons[pylon];
});
props.setSelectedWeapons(newSelectedWeapons);
}}
>
<FaXmark
className={`
inline text-red-500
`}
/>{" "}
Remove
</button>
)
}
</>
)}
{props.selectedWeapons && Object.keys(props.selectedWeapons).length > 0 && (
<button
className={`
text-nowrap rounded-md bg-gray-700 px-2 py-1
text-sm
hover:bg-gray-600
`}
onClick={() => {
// Clear all selected weapons
props.setSelectedWeapons({});
}}
>
<FaTrash className="inline text-red-500" /> Delete all
</button>
)}
</div> </div>
</div> </div>
@@ -193,6 +344,17 @@ export function WeaponsWizard(props: {
}} }}
/> />
</div> </div>
{autofillPylons && (
<div className="flex items-center justify-between gap-2">
<span className="ml-2 text-sm">Only fill empty pylons</span>
<OlToggle
toggled={fillEmptyOnly}
onClick={() => {
setFillEmptyOnly(!fillEmptyOnly);
}}
/>
</div>
)}
<OlSearchBar onChange={setSearchText} text={searchText} /> <OlSearchBar onChange={setSearchText} text={searchText} />
@@ -202,13 +364,35 @@ export function WeaponsWizard(props: {
border-gray-700 px-2 border-gray-700 px-2
`} `}
> >
{availableWeapons.length === 0 ? ( {selectedPylons.length === 0 && !autofillPylons && (
selectedPylons.length === 0 ? ( <div
<div className="p-2 text-sm text-gray-400">No pylons selected</div> className={`
) : ( p-2 text-sm text-gray-400
<div className="p-2 text-sm text-gray-400">No weapons compatible with all selected pylons</div> `}
) >
) : ( No pylons selected
</div>
)}
{availableWeapons.length === 0 && selectedPylons.length !== 0 && !autofillPylons && (
<div
className={`
p-2 text-sm text-gray-400
`}
>
No weapons compatible with all selected pylons
</div>
)}
{availableWeapons.length === 0 && selectedPylons.length === 0 && autofillPylons && (
<div
className={`
p-2 text-sm text-gray-400
`}
>
No empty pylons available
</div>
)}
{availableWeapons.length !== 0 &&
availableWeapons.map((weapon) => ( availableWeapons.map((weapon) => (
<div <div
key={weapon.name} key={weapon.name}
@@ -218,6 +402,10 @@ export function WeaponsWizard(props: {
let newSelectedWeapons = { ...props.selectedWeapons }; let newSelectedWeapons = { ...props.selectedWeapons };
Object.keys(props.weaponsByPylon).forEach((pylon) => { Object.keys(props.weaponsByPylon).forEach((pylon) => {
const weaponsInPylon = props.weaponsByPylon[pylon]; const weaponsInPylon = props.weaponsByPylon[pylon];
if (fillEmptyOnly && props.selectedWeapons[pylon]) {
// If "Only fill empty pylons" is enabled, skip filled pylons
return;
}
if (weaponsInPylon.some((w) => w.name === weapon.name)) { if (weaponsInPylon.some((w) => w.name === weapon.name)) {
newSelectedWeapons[pylon] = weapon; newSelectedWeapons[pylon] = weapon;
} }
@@ -233,6 +421,8 @@ export function WeaponsWizard(props: {
setSelectedPylons([]); setSelectedPylons([]);
} }
}} }}
onMouseEnter={() => setHoveredWeapon(weapon.name)}
onMouseLeave={() => setHoveredWeapon("")}
className={` className={`
cursor-pointer rounded-md p-1 text-sm cursor-pointer rounded-md p-1 text-sm
hover:bg-gray-700 hover:bg-gray-700
@@ -240,8 +430,7 @@ export function WeaponsWizard(props: {
> >
{weapon.name} {weapon.name}
</div> </div>
)) ))}
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import { OlNumberInput } from "../components/olnumberinput";
import { OlLabelToggle } from "../components/ollabeltoggle"; import { OlLabelToggle } from "../components/ollabeltoggle";
import { OlRangeSlider } from "../components/olrangeslider"; import { OlRangeSlider } from "../components/olrangeslider";
import { OlDropdownItem, OlDropdown } from "../components/oldropdown"; import { OlDropdownItem, OlDropdown } from "../components/oldropdown";
import { LoadoutBlueprint, SpawnRequestTable, UnitBlueprint } from "../../interfaces"; import { LoadoutBlueprint, SpawnRequestTable, UnitBlueprint, UnitSpawnTable } from "../../interfaces";
import { OlStateButton } from "../components/olstatebutton"; import { OlStateButton } from "../components/olstatebutton";
import { Coalition } from "../../types/types"; import { Coalition } from "../../types/types";
import { getApp } from "../../olympusapp"; import { getApp } from "../../olympusapp";
@@ -17,11 +17,10 @@ import { faArrowLeft, faStar } from "@fortawesome/free-solid-svg-icons";
import { OlStringInput } from "../components/olstringinput"; import { OlStringInput } from "../components/olstringinput";
import { countryCodes } from "../data/codes"; import { countryCodes } from "../data/codes";
import { OlAccordion } from "../components/olaccordion"; import { OlAccordion } from "../components/olaccordion";
import { AppStateChangedEvent, SpawnHeadingChangedEvent } from "../../events"; import { AppStateChangedEvent, CustomLoadoutsUpdatedEvent, SetLoadoutWizardBlueprintEvent, SpawnHeadingChangedEvent } from "../../events";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FaQuestionCircle } from "react-icons/fa"; import { FaMagic, FaQuestionCircle } from "react-icons/fa";
import { OlExpandingTooltip } from "../components/olexpandingtooltip"; import { OlExpandingTooltip } from "../components/olexpandingtooltip";
import { WeaponsWizard } from "./components/weaponswizard";
import { LoadoutViewer } from "./components/loadoutviewer"; import { LoadoutViewer } from "./components/loadoutviewer";
enum OpenAccordion { enum OpenAccordion {
@@ -59,7 +58,7 @@ export function UnitSpawnMenu(props: {
const [spawnAltitudeType, setSpawnAltitudeType] = useState(false); const [spawnAltitudeType, setSpawnAltitudeType] = useState(false);
const [spawnLiveryID, setSpawnLiveryID] = useState(""); const [spawnLiveryID, setSpawnLiveryID] = useState("");
const [spawnSkill, setSpawnSkill] = useState("High"); const [spawnSkill, setSpawnSkill] = useState("High");
const [selectedWeapons, setSelectedWeapons] = useState({} as { [key: string]: { clsids: string; name: string; weight: number } });
const [quickAccessName, setQuickAccessName] = useState("Preset 1"); const [quickAccessName, setQuickAccessName] = useState("Preset 1");
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [spawnRequestTable, setSpawnRequestTable] = useState(null as null | SpawnRequestTable); const [spawnRequestTable, setSpawnRequestTable] = useState(null as null | SpawnRequestTable);
@@ -70,13 +69,16 @@ export function UnitSpawnMenu(props: {
useEffect(() => { useEffect(() => {
setAppState(getApp()?.getState()); setAppState(getApp()?.getState());
AppStateChangedEvent.on((state, subState) => setAppState(state)); AppStateChangedEvent.on((state, subState) => setAppState(state));
CustomLoadoutsUpdatedEvent.on((unitName, loadout) => {
setSpawnRole(loadout.roles[0]);
setSpawnLoadout(loadout);
});
}, []); }, []);
useEffect(() => { useEffect(() => {
setSpawnRole(""); setSpawnRole("");
setSpawnLoadout(null); setSpawnLoadout(null);
setSpawnLiveryID(""); setSpawnLiveryID("");
setSelectedWeapons({});
}, [props.blueprint]); }, [props.blueprint]);
/* When the menu is opened show the unit preview on the map as a cursor */ /* When the menu is opened show the unit preview on the map as a cursor */
@@ -115,16 +117,25 @@ export function UnitSpawnMenu(props: {
/* Callback and effect to update the spawn request table */ /* Callback and effect to update the spawn request table */
const updateSpawnRequestTable = useCallback(() => { const updateSpawnRequestTable = useCallback(() => {
if (props.blueprint !== null) { if (props.blueprint !== null) {
const loadoutCode = spawnLoadout ? (props.blueprint.loadouts?.find((loadout) => loadout.name === spawnLoadout.name)?.code ?? "") : "";
const loadoutPayload = spawnLoadout
? (props.blueprint.loadouts?.find((loadout) => loadout.name === spawnLoadout.name)?.payload ?? undefined)
: undefined;
const unitTable: UnitSpawnTable = {
unitType: props.blueprint?.name,
location: props.latlng ?? new LatLng(0, 0), // This will be filled when the user clicks on the map to spawn the unit
skill: spawnSkill,
liveryID: spawnLiveryID,
altitude: ftToM(spawnAltitude),
loadout: loadoutCode,
};
if (loadoutPayload) unitTable.payload = loadoutPayload;
setSpawnRequestTable({ setSpawnRequestTable({
category: props.blueprint?.category, category: props.blueprint?.category,
unit: { unit: unitTable,
unitType: props.blueprint?.name,
location: props.latlng ?? new LatLng(0, 0), // This will be filled when the user clicks on the map to spawn the unit
skill: spawnSkill,
liveryID: spawnLiveryID,
altitude: ftToM(spawnAltitude),
loadout: props.blueprint?.loadouts?.find((loadout) => loadout.name === spawnLoadout?.name)?.code ?? "",
},
amount: spawnNumber, amount: spawnNumber,
coalition: spawnCoalition, coalition: spawnCoalition,
}); });
@@ -192,10 +203,49 @@ export function UnitSpawnMenu(props: {
/* Initialize the role */ /* Initialize the role */
let allRoles = props.blueprint?.loadouts?.flatMap((loadout) => loadout.roles).filter((role) => role !== "No task"); let allRoles = props.blueprint?.loadouts?.flatMap((loadout) => loadout.roles).filter((role) => role !== "No task");
let mainRole = roles[0];
// If there are loadouts with Custom role, include Custom in the role selection
const hasCustomRoleLoadouts = props.blueprint?.loadouts?.some((loadout) => loadout.roles.includes("Custom"));
if (hasCustomRoleLoadouts && allRoles) allRoles.push("Custom");
// If there are custom loadouts, select "Custom" as the main role
let mainRole = hasCustomRoleLoadouts ? "Custom" : roles[0];
if (allRoles !== undefined) mainRole = mode(allRoles); if (allRoles !== undefined) mainRole = mode(allRoles);
spawnRole === "" && roles.length > 0 && setSpawnRole(mainRole); spawnRole === "" && roles.length > 0 && setSpawnRole(mainRole);
// Filter the loadouts based on the selected role
const filteredLoadouts = props.blueprint?.loadouts?.filter((loadout) => loadout.roles.includes(spawnRole));
// Order the loadouts so that custom loadouts appear first and "Empty loadout" appears last
if (filteredLoadouts) {
filteredLoadouts.sort((a, b) => {
if (a.isCustom && !b.isCustom) return -1;
if (!a.isCustom && b.isCustom) return 1;
if (a.name === "Empty loadout") return 1;
if (b.name === "Empty loadout") return -1;
return 0;
});
}
/* Effect to reset the loadout if the role changes */
useEffect(() => {
// If the current loadout is not in the filtered loadouts, reset it
if (spawnLoadout && filteredLoadouts && !filteredLoadouts.includes(spawnLoadout)) {
setSpawnLoadout(null);
}
}, [spawnRole, props.blueprint]);
/* Initialize the loadout */
const initializeLoadout = useCallback(() => {
if (spawnLoadout && filteredLoadouts && filteredLoadouts.includes(spawnLoadout)) return;
if (filteredLoadouts && filteredLoadouts.length > 0) {
if (filteredLoadouts.filter((loadout) => loadout.name !== "Empty loadout").length > 0)
setSpawnLoadout(filteredLoadouts.filter((loadout) => loadout.name !== "Empty loadout")[0]);
else setSpawnLoadout(filteredLoadouts[0]);
}
}, [filteredLoadouts, spawnLoadout]);
useEffect(initializeLoadout, [filteredLoadouts]);
return ( return (
<> <>
{props.compact ? ( {props.compact ? (
@@ -207,7 +257,7 @@ export function UnitSpawnMenu(props: {
`} `}
> >
<div className="mr-2"> <div className="mr-2">
<div className="flex h-fit flex-col gap-3"> <div className="flex h-fit flex-col gap-2">
<div className="flex"> <div className="flex">
<FontAwesomeIcon <FontAwesomeIcon
onClick={props.onBack} onClick={props.onBack}
@@ -246,10 +296,11 @@ export function UnitSpawnMenu(props: {
> >
<div <div
className={` className={`
my-auto text-sm text-white my-auto text-nowrap text-sm
text-white
`} `}
> >
Quick access:{" "} Quick access:
</div> </div>
<OlStringInput <OlStringInput
onChange={(e) => { onChange={(e) => {
@@ -372,6 +423,87 @@ export function UnitSpawnMenu(props: {
})} })}
</OlDropdown> </OlDropdown>
</div> </div>
<div
className={`
flex flex-col content-center
justify-between gap-2
`}
>
<span
className={`
my-auto font-normal
dark:text-gray-400
`}
>
Loadout
</span>
<OlDropdown
label={spawnLoadout ? spawnLoadout.name : "Default"}
className={`w-full`}
tooltip={() => (
<OlExpandingTooltip
title="Unit loadout"
content="Selects the loadout of the spawned unit. Depending on the selection, the unit will be equipped with different weapons and equipment."
/>
)}
tooltipRelativeToParent={true}
>
{filteredLoadouts?.map((loadout) => {
return (
<OlDropdownItem
key={loadout.name}
onClick={() => {
setSpawnLoadout(loadout);
}}
className={`
w-full
`}
>
<span
className={`
w-full
content-center
overflow-hidden
text-ellipsis
text-nowrap
text-left
w-max-full
flex gap-2
`}
>
<div
className={`
my-auto
`}
>
{loadout.name}
</div>
</span>
</OlDropdownItem>
);
})}
</OlDropdown>
</div>
<div>
<button
type="button"
className={`
flex w-full justify-center
gap-2 rounded-md
border-[1px] p-2
align-middle text-sm
dark:text-white
hover:bg-white/10
`}
onClick={() => {
SetLoadoutWizardBlueprintEvent.dispatch(props.blueprint!);
getApp().setState(OlympusState.SPAWN, SpawnSubState.LOADOUT_WIZARD);
}}
>
<FaMagic className="my-auto" />
Loadout wizard
</button>
</div>
</> </>
)} )}
<OlAccordion <OlAccordion
@@ -640,21 +772,6 @@ export function UnitSpawnMenu(props: {
> >
{props.blueprint ? <OlUnitSummary blueprint={props.blueprint} coalition={spawnCoalition} /> : <span></span>} {props.blueprint ? <OlUnitSummary blueprint={props.blueprint} coalition={spawnCoalition} /> : <span></span>}
</OlAccordion> </OlAccordion>
<OlAccordion
onClick={() => {
setOpenAccordion(openAccordion === OpenAccordion.LOADOUT ? OpenAccordion.NONE : OpenAccordion.LOADOUT);
}}
open={openAccordion === OpenAccordion.LOADOUT}
title="Loadout wizard"
>
<WeaponsWizard
selectedWeapons={selectedWeapons}
setSelectedWeapons={setSelectedWeapons}
weaponsByPylon={props.blueprint?.acceptedPayloads ?? {}}
/>
</OlAccordion>
{spawnLoadout && spawnLoadout.items.length > 0 && ( {spawnLoadout && spawnLoadout.items.length > 0 && (
<OlAccordion <OlAccordion
onClick={() => { onClick={() => {
@@ -1056,6 +1173,75 @@ export function UnitSpawnMenu(props: {
})} })}
</OlDropdown> </OlDropdown>
</div> </div>
<div
className={`
flex flex-col content-center
justify-between gap-2
`}
>
<span
className={`
my-auto font-normal
dark:text-gray-400
`}
>
Loadout
</span>
<OlDropdown
label={spawnLoadout ? spawnLoadout.name : "Default"}
className={`w-full`}
tooltip={() => (
<OlExpandingTooltip
title="Unit loadout"
content="Selects the loadout of the spawned unit. Depending on the selection, the unit will be equipped with different weapons and equipment."
/>
)}
tooltipRelativeToParent={true}
>
{filteredLoadouts?.map((loadout) => {
return (
<OlDropdownItem
key={loadout.name}
onClick={() => {
setSpawnLoadout(loadout);
}}
className={`w-full`}
>
<span
className={`
w-full content-center
overflow-hidden
text-ellipsis
text-nowrap text-left
w-max-full flex gap-2
`}
>
<div className="my-auto">{loadout.name}</div>
</span>
</OlDropdownItem>
);
})}
</OlDropdown>
</div>
<div>
<button
type="button"
className={`
flex w-full justify-center gap-2
rounded-md border-[1px] p-2
align-middle text-sm
dark:text-white
hover:bg-white/10
`}
onClick={() => {
SetLoadoutWizardBlueprintEvent.dispatch(props.blueprint!);
getApp().setState(OlympusState.SPAWN, SpawnSubState.LOADOUT_WIZARD);
}}
>
<FaMagic className="my-auto" />
Loadout wizard
</button>
</div>
<div className="my-5 flex justify-between"> <div className="my-5 flex justify-between">
<div className="my-auto flex flex-col gap-2"> <div className="my-auto flex flex-col gap-2">
<span className="text-white">Spawn heading</span> <span className="text-white">Spawn heading</span>
@@ -1069,13 +1255,7 @@ export function UnitSpawnMenu(props: {
my-auto my-auto
`} `}
/>{" "} />{" "}
<div <div className={`my-auto`}>Drag to change</div>
className={`
my-auto
`}
>
Drag to change
</div>
</div> </div>
</div> </div>
@@ -1128,38 +1308,8 @@ export function UnitSpawnMenu(props: {
></img> ></img>
</div> </div>
</div> </div>
</div> <div className="text-gray-200">Loadout</div>
{spawnLoadout && <LoadoutViewer spawnLoadout={spawnLoadout} />}
<div
className={`
flex h-fit flex-col gap-1 px-4 py-2
dark:bg-olympus-200/30
`}
>
<OlAccordion
onClick={() => {
setOpenAccordion(openAccordion === OpenAccordion.LOADOUT ? OpenAccordion.NONE : OpenAccordion.LOADOUT);
}}
open={openAccordion === OpenAccordion.LOADOUT}
title="Loadout wizard"
>
<WeaponsWizard
selectedWeapons={selectedWeapons}
setSelectedWeapons={setSelectedWeapons}
weaponsByPylon={props.blueprint?.acceptedPayloads ?? {}}
/>
</OlAccordion>
{spawnLoadout && spawnLoadout.items.length > 0 && (
<OlAccordion
onClick={() => {
setShowLoadout(!showLoadout);
}}
open={showLoadout}
title="Loadout"
>
<LoadoutViewer spawnLoadout={spawnLoadout} />
</OlAccordion>
)}
</div> </div>
{props.airbase && ( {props.airbase && (

View File

@@ -7,7 +7,7 @@ import { UnitControlMenu } from "./panels/unitcontrolmenu";
import { MainMenu } from "./panels/mainmenu"; import { MainMenu } from "./panels/mainmenu";
import { SideBar } from "./panels/sidebar"; import { SideBar } from "./panels/sidebar";
import { OptionsMenu } from "./panels/optionsmenu"; import { OptionsMenu } from "./panels/optionsmenu";
import { NO_SUBSTATE, OlympusState, OlympusSubState, OptionsSubstate, UnitControlSubState } from "../constants/constants"; import { NO_SUBSTATE, OlympusState, OlympusSubState, OptionsSubstate, SpawnSubState, UnitControlSubState } from "../constants/constants";
import { getApp, setupApp } from "../olympusapp"; import { getApp, setupApp } from "../olympusapp";
import { LoginModal } from "./modals/loginmodal"; import { LoginModal } from "./modals/loginmodal";
@@ -32,6 +32,7 @@ import { WarningModal } from "./modals/warningmodal";
import { TrainingModal } from "./modals/trainingmodal"; import { TrainingModal } from "./modals/trainingmodal";
import { AdminModal } from "./modals/adminmodal"; import { AdminModal } from "./modals/adminmodal";
import { ImageOverlayModal } from "./modals/imageoverlaymodal"; import { ImageOverlayModal } from "./modals/imageoverlaymodal";
import { LoadoutWizardModal } from "./modals/loadoutwizardmodal";
export function UI() { export function UI() {
const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED); const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED);
@@ -76,6 +77,7 @@ export function UI() {
<TrainingModal open={appState === OlympusState.TRAINING} /> <TrainingModal open={appState === OlympusState.TRAINING} />
<AdminModal open={appState === OlympusState.ADMIN} /> <AdminModal open={appState === OlympusState.ADMIN} />
<ImageOverlayModal open={appState === OlympusState.IMPORT_IMAGE_OVERLAY} /> <ImageOverlayModal open={appState === OlympusState.IMPORT_IMAGE_OVERLAY} />
<LoadoutWizardModal open={appState === OlympusState.SPAWN && appSubState === SpawnSubState.LOADOUT_WIZARD} />
</> </>
)} )}

View File

@@ -1,12 +1,26 @@
import { getApp } from "../../olympusapp"; import { getApp } from "../../olympusapp";
import { GAME_MASTER } from "../../constants/constants"; import { GAME_MASTER } from "../../constants/constants";
import { UnitBlueprint } from "../../interfaces"; import { UnitBlueprint } from "../../interfaces";
import { UnitDatabaseLoadedEvent } from "../../events"; import { SessionDataLoadedEvent, UnitDatabaseLoadedEvent } from "../../events";
export class UnitDatabase { export class UnitDatabase {
blueprints: { [key: string]: UnitBlueprint } = {}; blueprints: { [key: string]: UnitBlueprint } = {};
constructor() {} constructor() {
SessionDataLoadedEvent.on((sessionData) => {
// Check if the sessionData customloadouts contains any loadouts for units, and if so, update the blueprints
if (sessionData.customLoadouts) {
for (let unitName in sessionData.customLoadouts) {
if (this.blueprints[unitName]) {
if (!this.blueprints[unitName].loadouts) this.blueprints[unitName].loadouts = [];
sessionData.customLoadouts[unitName].forEach((loadout) => {
this.blueprints[unitName].loadouts?.push(loadout);
});
}
}
}
});
}
load(url: string, category?: string) { load(url: string, category?: string) {
if (url !== "") { if (url !== "") {
@@ -204,7 +218,7 @@ export class UnitDatabase {
getLoadoutNamesByRole(name: string, role: string) { getLoadoutNamesByRole(name: string, role: string) {
var filteredBlueprints = this.getBlueprints(); var filteredBlueprints = this.getBlueprints();
var loadoutsByRole: string[] = []; var loadoutsByRole: string[] = [];
var loadouts = filteredBlueprints[name].loadouts; var loadouts = filteredBlueprints[name as any].loadouts;
if (loadouts) { if (loadouts) {
for (let loadout of loadouts) { for (let loadout of loadouts) {
if (loadout.roles.includes(role) || loadout.roles.includes("")) { if (loadout.roles.includes(role) || loadout.roles.includes("")) {