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 liveryID;
double heading;
string payload;
};
struct CloneOptions {

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
AudioSinksChangedEvent,
AudioSourcesChangedEvent,
CoalitionAreasChangedEvent,
CustomLoadoutsUpdatedEvent,
DrawingsUpdatedEvent,
HotgroupsChangedEvent,
MapSourceChangedEvent,
@ -16,7 +17,7 @@ import {
SessionDataSavedEvent,
StarredSpawnsChangedEvent,
} from "./events";
import { SessionData } from "./interfaces";
import { LoadoutBlueprint, SessionData } from "./interfaces";
import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle";
import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon";
import { getApp } from "./olympusapp";
@ -124,7 +125,7 @@ export class SessionDataManager {
HotgroupsChangedEvent.on((hotgroups) => {
this.#sessionData.hotgroups = {};
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();
});
@ -146,6 +147,16 @@ export class SessionDataManager {
this.#sessionData.mapSource = { id: source };
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);
});
}

View File

@ -8,8 +8,9 @@ export function Modal(props: {
open: boolean;
children?: JSX.Element | JSX.Element[];
className?: string;
size?: "sm" | "md" | "lg" | "full";
size?: "sm" | "md" | "lg" | "full" | "tall";
disableClose?: boolean;
onClose?: () => void;
}) {
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" : ""}
`}
>
@ -90,7 +99,7 @@ export function Modal(props: {
>
<FaXmark
onClick={() => {
getApp().setState(OlympusState.IDLE);
props.onClose ? props.onClose() : getApp().setState(OlympusState.IDLE);
}}
/>{" "}
</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 { OlDropdown, OlDropdownItem } from "../../components/oldropdown";
import React, { useEffect, useState } from "react";
import { FaArrowsRotate, FaTrash, FaXmark } from "react-icons/fa6";
import { OlSearchBar } from "../../components/olsearchbar";
import { OlCheckbox } from "../../components/olcheckbox";
import { OlToggle } from "../../components/oltoggle";
export function WeaponsWizard(props: {
selectedWeapons: { [key: string]: { clsids: string; name: string; weight: number } };
setSelectedWeapons: (weapons: { [key: string]: { clsids: string; name: string; weight: number } }) => void;
weaponsByPylon: { [key: string]: { clsids: string; name: string; weight: number }[] };
selectedWeapons: { [key: string]: { clsid: string; name: string; weight: number } };
setSelectedWeapons: (weapons: { [key: string]: { clsid: string; name: string; weight: number } }) => void;
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 [selectedPylons, setSelectedPylons] = useState<string[]>([]);
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
let availableWeapons: { clsids: string; name: string; weight: number }[] = [];
let availableWeapons: { clsid: string; name: string; weight: number }[] = [];
if (autofillPylons) {
// If autofill is enabled, show all weapons
availableWeapons = Object.values(props.weaponsByPylon).flat();
@ -39,91 +57,266 @@ export function WeaponsWizard(props: {
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 (
<div>
<div className="flex flex-col gap-2">
<div className="flex justify-center">
{Object.keys(props.weaponsByPylon).map((pylon) => (
<div key={pylon} className={``}>
<div className="flex flex-col gap-6 text-white" onMouseEnter={() => setHoveredWeapon("")}>
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<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
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`
}
border-b-2 border- b-2 w-full border-gray-300
`}
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 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
className={`
h-14 min-w-14 rounded-full border-2
border-gray-300
`}
></div>
<div
className={`
border-b-2 border- b-2 w-full border-gray-300
`}
></div>
</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 && (
<>
<div className="flex justify-center gap-1">
{Object.keys(props.weaponsByPylon).map((pylon) => {
let weapon = props.selectedWeapons[pylon];
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
className={`
text-nowrap rounded-md bg-gray-700 px-2
@ -131,56 +324,14 @@ export function WeaponsWizard(props: {
hover:bg-gray-600
`}
onClick={() => {
setSelectedPylons([]);
// Clear all selected weapons
props.setSelectedWeapons({});
}}
>
<FaArrowsRotate className="inline" /> Reset selection
<FaTrash className="inline text-red-500" /> Delete all
</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
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>
@ -193,6 +344,17 @@ export function WeaponsWizard(props: {
}}
/>
</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} />
@ -202,13 +364,35 @@ export function WeaponsWizard(props: {
border-gray-700 px-2
`}
>
{availableWeapons.length === 0 ? (
selectedPylons.length === 0 ? (
<div className="p-2 text-sm text-gray-400">No pylons selected</div>
) : (
<div className="p-2 text-sm text-gray-400">No weapons compatible with all selected pylons</div>
)
) : (
{selectedPylons.length === 0 && !autofillPylons && (
<div
className={`
p-2 text-sm text-gray-400
`}
>
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) => (
<div
key={weapon.name}
@ -218,6 +402,10 @@ export function WeaponsWizard(props: {
let newSelectedWeapons = { ...props.selectedWeapons };
Object.keys(props.weaponsByPylon).forEach((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)) {
newSelectedWeapons[pylon] = weapon;
}
@ -233,6 +421,8 @@ export function WeaponsWizard(props: {
setSelectedPylons([]);
}
}}
onMouseEnter={() => setHoveredWeapon(weapon.name)}
onMouseLeave={() => setHoveredWeapon("")}
className={`
cursor-pointer rounded-md p-1 text-sm
hover:bg-gray-700
@ -240,8 +430,7 @@ export function WeaponsWizard(props: {
>
{weapon.name}
</div>
))
)}
))}
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ import { OlNumberInput } from "../components/olnumberinput";
import { OlLabelToggle } from "../components/ollabeltoggle";
import { OlRangeSlider } from "../components/olrangeslider";
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 { Coalition } from "../../types/types";
import { getApp } from "../../olympusapp";
@ -17,11 +17,10 @@ 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, SpawnHeadingChangedEvent } from "../../events";
import { AppStateChangedEvent, CustomLoadoutsUpdatedEvent, SetLoadoutWizardBlueprintEvent, SpawnHeadingChangedEvent } from "../../events";
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 { WeaponsWizard } from "./components/weaponswizard";
import { LoadoutViewer } from "./components/loadoutviewer";
enum OpenAccordion {
@ -59,7 +58,7 @@ export function UnitSpawnMenu(props: {
const [spawnAltitudeType, setSpawnAltitudeType] = useState(false);
const [spawnLiveryID, setSpawnLiveryID] = useState("");
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 [key, setKey] = useState("");
const [spawnRequestTable, setSpawnRequestTable] = useState(null as null | SpawnRequestTable);
@ -70,13 +69,16 @@ export function UnitSpawnMenu(props: {
useEffect(() => {
setAppState(getApp()?.getState());
AppStateChangedEvent.on((state, subState) => setAppState(state));
CustomLoadoutsUpdatedEvent.on((unitName, loadout) => {
setSpawnRole(loadout.roles[0]);
setSpawnLoadout(loadout);
});
}, []);
useEffect(() => {
setSpawnRole("");
setSpawnLoadout(null);
setSpawnLiveryID("");
setSelectedWeapons({});
}, [props.blueprint]);
/* 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 */
const updateSpawnRequestTable = useCallback(() => {
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({
category: props.blueprint?.category,
unit: {
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 ?? "",
},
unit: unitTable,
amount: spawnNumber,
coalition: spawnCoalition,
});
@ -192,10 +203,49 @@ export function UnitSpawnMenu(props: {
/* Initialize the role */
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);
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 (
<>
{props.compact ? (
@ -207,7 +257,7 @@ export function UnitSpawnMenu(props: {
`}
>
<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">
<FontAwesomeIcon
onClick={props.onBack}
@ -246,10 +296,11 @@ export function UnitSpawnMenu(props: {
>
<div
className={`
my-auto text-sm text-white
my-auto text-nowrap text-sm
text-white
`}
>
Quick access:{" "}
Quick access:
</div>
<OlStringInput
onChange={(e) => {
@ -372,6 +423,87 @@ export function UnitSpawnMenu(props: {
})}
</OlDropdown>
</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
@ -640,21 +772,6 @@ export function UnitSpawnMenu(props: {
>
{props.blueprint ? <OlUnitSummary blueprint={props.blueprint} coalition={spawnCoalition} /> : <span></span>}
</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 && (
<OlAccordion
onClick={() => {
@ -1056,6 +1173,75 @@ export function UnitSpawnMenu(props: {
})}
</OlDropdown>
</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-auto flex flex-col gap-2">
<span className="text-white">Spawn heading</span>
@ -1069,13 +1255,7 @@ export function UnitSpawnMenu(props: {
my-auto
`}
/>{" "}
<div
className={`
my-auto
`}
>
Drag to change
</div>
<div className={`my-auto`}>Drag to change</div>
</div>
</div>
@ -1128,38 +1308,8 @@ export function UnitSpawnMenu(props: {
></img>
</div>
</div>
</div>
<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 className="text-gray-200">Loadout</div>
{spawnLoadout && <LoadoutViewer spawnLoadout={spawnLoadout} />}
</div>
{props.airbase && (

View File

@ -7,7 +7,7 @@ import { UnitControlMenu } from "./panels/unitcontrolmenu";
import { MainMenu } from "./panels/mainmenu";
import { SideBar } from "./panels/sidebar";
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 { LoginModal } from "./modals/loginmodal";
@ -32,6 +32,7 @@ import { WarningModal } from "./modals/warningmodal";
import { TrainingModal } from "./modals/trainingmodal";
import { AdminModal } from "./modals/adminmodal";
import { ImageOverlayModal } from "./modals/imageoverlaymodal";
import { LoadoutWizardModal } from "./modals/loadoutwizardmodal";
export function UI() {
const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED);
@ -76,6 +77,7 @@ export function UI() {
<TrainingModal open={appState === OlympusState.TRAINING} />
<AdminModal open={appState === OlympusState.ADMIN} />
<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 { GAME_MASTER } from "../../constants/constants";
import { UnitBlueprint } from "../../interfaces";
import { UnitDatabaseLoadedEvent } from "../../events";
import { SessionDataLoadedEvent, UnitDatabaseLoadedEvent } from "../../events";
export class UnitDatabase {
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) {
if (url !== "") {
@ -204,7 +218,7 @@ export class UnitDatabase {
getLoadoutNamesByRole(name: string, role: string) {
var filteredBlueprints = this.getBlueprints();
var loadoutsByRole: string[] = [];
var loadouts = filteredBlueprints[name].loadouts;
var loadouts = filteredBlueprints[name as any].loadouts;
if (loadouts) {
for (let loadout of loadouts) {
if (loadout.roles.includes(role) || loadout.roles.includes("")) {