From bdc1e36e6313f75a97b0637a2938ad2513fce7da Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sat, 14 Mar 2026 03:06:18 -0500 Subject: [PATCH] Implement interactive garage UI with service-based client bridge - replace placeholder garage interaction with real UI open flow - add catalog/session/UI bridge services for hydrate, sync, store, and retrieve actions - migrate garage web UI bundle to new app shell/runtime structure - align org/store function naming with shared init and UI bridge patterns --- .../actor/functions/fnc_handleUIEvents.sqf | 2 +- arma/client/addons/garage/XEH_PREP.hpp | 7 +- .../addons/garage/XEH_postInitClient.sqf | 19 +- arma/client/addons/garage/config.cpp | 3 + .../garage/functions/fnc_handleUIEvents.sqf | 43 + .../functions/fnc_initCatalogService.sqf | 160 ++ ..._initGarageClass.sqf => fnc_initClass.sqf} | 9 +- .../functions/fnc_initSessionService.sqf | 298 ++++ .../garage/functions/fnc_initUIBridge.sqf | 205 +++ .../addons/garage/functions/fnc_openUI.sqf | 17 + arma/client/addons/garage/ui/RscGarage.hpp | 4 +- .../addons/garage/ui/_site/garage-ui.css | 574 ++++++++ .../addons/garage/ui/_site/garage-ui.js | 1291 +++++++++++++++++ arma/client/addons/garage/ui/_site/index.html | 299 +--- arma/client/addons/garage/ui/_site/script.js | 472 ------ arma/client/addons/garage/ui/_site/style.css | 605 -------- arma/client/addons/garage/ui/src/bootstrap.js | 19 + arma/client/addons/garage/ui/src/bridge.js | 87 ++ .../garage/ui/src/components/AppShell.js | 827 +++++++++++ arma/client/addons/garage/ui/src/data.js | 57 + .../addons/garage/ui/src/registry/events.js | 174 +++ .../addons/garage/ui/src/registry/store.js | 113 ++ arma/client/addons/garage/ui/src/runtime.js | 7 + arma/client/addons/garage/ui/src/styles.css | 573 ++++++++ arma/client/addons/garage/ui/ui.config.mjs | 33 + arma/client/addons/org/XEH_PREP.hpp | 4 +- arma/client/addons/org/XEH_postInitClient.sqf | 4 +- ...fnc_initOrgClass.sqf => fnc_initClass.sqf} | 4 +- ...itOrgUIBridge.sqf => fnc_initUIBridge.sqf} | 4 +- arma/client/addons/store/XEH_PREP.hpp | 8 +- .../addons/store/XEH_postInitClient.sqf | 6 +- ...reUIPayload.sqf => fnc_buildUIPayload.sqf} | 2 +- ...Service.sqf => fnc_initCatalogService.sqf} | 2 +- ...c_initStoreClass.sqf => fnc_initClass.sqf} | 4 +- ...StoreUIBridge.sqf => fnc_initUIBridge.sqf} | 6 +- arma/client/addons/store/ui/_site/store-ui.js | 28 +- .../addons/store/ui/src/components/cart.js | 28 +- arma/server/addons/garage/XEH_preInit.sqf | 78 + 38 files changed, 4689 insertions(+), 1387 deletions(-) create mode 100644 arma/client/addons/garage/functions/fnc_initCatalogService.sqf rename arma/client/addons/garage/functions/{fnc_initGarageClass.sqf => fnc_initClass.sqf} (90%) create mode 100644 arma/client/addons/garage/functions/fnc_initSessionService.sqf create mode 100644 arma/client/addons/garage/functions/fnc_initUIBridge.sqf create mode 100644 arma/client/addons/garage/ui/_site/garage-ui.css create mode 100644 arma/client/addons/garage/ui/_site/garage-ui.js delete mode 100644 arma/client/addons/garage/ui/_site/script.js delete mode 100644 arma/client/addons/garage/ui/_site/style.css create mode 100644 arma/client/addons/garage/ui/src/bootstrap.js create mode 100644 arma/client/addons/garage/ui/src/bridge.js create mode 100644 arma/client/addons/garage/ui/src/components/AppShell.js create mode 100644 arma/client/addons/garage/ui/src/data.js create mode 100644 arma/client/addons/garage/ui/src/registry/events.js create mode 100644 arma/client/addons/garage/ui/src/registry/store.js create mode 100644 arma/client/addons/garage/ui/src/runtime.js create mode 100644 arma/client/addons/garage/ui/src/styles.css create mode 100644 arma/client/addons/garage/ui/ui.config.mjs rename arma/client/addons/org/functions/{fnc_initOrgClass.sqf => fnc_initClass.sqf} (98%) rename arma/client/addons/org/functions/{fnc_initOrgUIBridge.sqf => fnc_initUIBridge.sqf} (98%) rename arma/client/addons/store/functions/{fnc_buildStoreUIPayload.sqf => fnc_buildUIPayload.sqf} (99%) rename arma/client/addons/store/functions/{fnc_initStoreCatalogService.sqf => fnc_initCatalogService.sqf} (99%) rename arma/client/addons/store/functions/{fnc_initStoreClass.sqf => fnc_initClass.sqf} (92%) rename arma/client/addons/store/functions/{fnc_initStoreUIBridge.sqf => fnc_initUIBridge.sqf} (96%) diff --git a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf index 1eb31c0..869b624 100644 --- a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf @@ -36,7 +36,7 @@ switch (_event) do { case "actor::open::atm": { [true] spawn EFUNC(bank,openUI); }; case "actor::open::bank": { [] spawn EFUNC(bank,openUI); }; case "actor::open::device": { hint "Device interaction is not yet implemented."; }; - case "actor::open::garage": { hint "Garage interaction is not yet implemented."; }; + case "actor::open::garage": { [] spawn EFUNC(garage,openUI); }; case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); }; case "actor::open::org": { [] spawn EFUNC(org,openUI); }; case "actor::open::vlocker": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) }; diff --git a/arma/client/addons/garage/XEH_PREP.hpp b/arma/client/addons/garage/XEH_PREP.hpp index 98bd123..9db23f1 100644 --- a/arma/client/addons/garage/XEH_PREP.hpp +++ b/arma/client/addons/garage/XEH_PREP.hpp @@ -1,3 +1,8 @@ -PREP(initGarageClass); +PREP(handleUIEvents); +PREP(initCatalogService); +PREP(initClass); +PREP(initSessionService); +PREP(initUIBridge); PREP(initVGClass); +PREP(openUI); PREP(openVG); diff --git a/arma/client/addons/garage/XEH_postInitClient.sqf b/arma/client/addons/garage/XEH_postInitClient.sqf index 0234ac5..7d53dc5 100644 --- a/arma/client/addons/garage/XEH_postInitClient.sqf +++ b/arma/client/addons/garage/XEH_postInitClient.sqf @@ -1,6 +1,9 @@ #include "script_component.hpp" -if (isNil QGVAR(GarageClass)) then { call FUNC(initGarageClass); }; +if (isNil QGVAR(GarageCatalogService)) then { call FUNC(initCatalogService); }; +if (isNil QGVAR(GarageClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(GarageSessionService)) then { call FUNC(initSessionService); }; +if (isNil QGVAR(GarageUIBridge)) then { call FUNC(initUIBridge); }; if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; [QGVAR(initGarage), { @@ -11,12 +14,26 @@ if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; params [["_data", createHashMap, [createHashMap]]]; GVAR(GarageClass) call ["sync", [_data]]; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; }] call CFUNC(addEventHandler); [QGVAR(responseSyncGarage), { params [["_data", createHashMap, [createHashMap, []]]]; GVAR(GarageClass) call ["sync", [_data]]; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; +}] call CFUNC(addEventHandler); + +[QGVAR(responseGarageAction), { + params [["_payload", createHashMap, [createHashMap]]]; + + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleActionResponse", [_payload]]; + }; }] call CFUNC(addEventHandler); [QGVAR(initVG), { diff --git a/arma/client/addons/garage/config.cpp b/arma/client/addons/garage/config.cpp index 595b77a..07836a5 100644 --- a/arma/client/addons/garage/config.cpp +++ b/arma/client/addons/garage/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; @@ -17,3 +18,5 @@ class CfgPatches { }; #include "CfgEventHandlers.hpp" +#include "ui\RscCommon.hpp" +#include "ui\RscGarage.hpp" diff --git a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf index 2fe5dfb..ae6ee6e 100644 --- a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf @@ -21,3 +21,46 @@ * Example: * call forge_client_garage_fnc_handleUIEvents; */ + +params ["_control", "_isConfirmDialog", "_message"]; + +private _alert = fromJSON _message; +private _event = _alert get "event"; +private _data = _alert get "data"; + +diag_log format ["[FORGE:Client:Garage] Handling UI event: %1 with data: %2", _event, _data]; + +switch (_event) do { + case "garage::close": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleClose", []]; + }; + + closeDialog 1; + }; + case "garage::ready": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleReady", [_control, _data]]; + }; + }; + case "garage::vehicle::retrieve::request": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleRetrieveRequest", [_data]]; + }; + }; + case "garage::vehicle::store::request": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleStoreRequest", [_data]]; + }; + }; + case "garage::refresh": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + default { + hint format ["Unhandled garage UI event: %1", _event]; + }; +}; + +true; diff --git a/arma/client/addons/garage/functions/fnc_initCatalogService.sqf b/arma/client/addons/garage/functions/fnc_initCatalogService.sqf new file mode 100644 index 0000000..748b5e4 --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initCatalogService.sqf @@ -0,0 +1,160 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initCatalogService.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the garage catalog service for vehicle metadata and UI-friendly shaping. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(GarageCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageCatalogServiceBaseClass"], + ["resolveCategory", compileFinal { + params [["_className", "", [""]]]; + + if (_className isEqualTo "") exitWith { "other" }; + + switch (true) do { + case (_className isKindOf ["Car", configFile >> "CfgVehicles"]): { "car" }; + case (_className isKindOf ["Tank", configFile >> "CfgVehicles"]): { "armor" }; + case (_className isKindOf ["Helicopter", configFile >> "CfgVehicles"]): { "air" }; + case (_className isKindOf ["Plane", configFile >> "CfgVehicles"]): { "air" }; + case (_className isKindOf ["Ship", configFile >> "CfgVehicles"]): { "naval" }; + default { "other" }; + } + }], + ["resolveDisplayName", compileFinal { + params [["_className", "", [""]]]; + + private _displayName = getText (configFile >> "CfgVehicles" >> _className >> "displayName"); + if (_displayName isEqualTo "") then { + _displayName = _className; + }; + + _displayName + }], + ["resolvePicture", compileFinal { + params [["_className", "", [""]]]; + + private _picture = getText (configFile >> "CfgVehicles" >> _className >> "editorPreview"); + if (_picture isEqualTo "") then { + _picture = getText (configFile >> "CfgVehicles" >> _className >> "picture"); + }; + + _picture + }], + ["buildHitPointRows", compileFinal { + params [["_hitPoints", createHashMap, [createHashMap]]]; + + private _rows = []; + private _names = _hitPoints getOrDefault ["names", []]; + private _selections = _hitPoints getOrDefault ["selections", []]; + private _values = _hitPoints getOrDefault ["values", []]; + private _count = count _names; + + for "_index" from 0 to (_count - 1) do { + private _rowName = _names param [_index, ""]; + _rows pushBack (createHashMapFromArray [ + ["name", _rowName], + ["selection", _selections param [_index, ""]], + ["value", _values param [_index, 0]] + ]); + }; + + _rows + }], + ["resolveHealth", compileFinal { + params [["_damage", 0, [0]], ["_hitPointRows", [], [[]]]]; + + private _worstHitPoint = 0; + { + private _value = _x getOrDefault ["value", 0]; + if (_value > _worstHitPoint) then { + _worstHitPoint = _value; + }; + } forEach _hitPointRows; + + 1 - ((_damage max _worstHitPoint) min 1) + }], + ["buildStoredVehicle", compileFinal { + params [["_plate", "", [""]], ["_vehicleData", createHashMap, [createHashMap]]]; + + private _className = _vehicleData getOrDefault ["classname", ""]; + private _damage = _vehicleData getOrDefault ["damage", 0]; + private _fuel = _vehicleData getOrDefault ["fuel", 0]; + private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; + private _hitPointRows = _self call ["buildHitPointRows", [_hitPoints]]; + + createHashMapFromArray [ + ["entryKind", "stored"], + ["plate", _plate], + ["classname", _className], + ["displayName", _self call ["resolveDisplayName", [_className]]], + ["picture", _self call ["resolvePicture", [_className]]], + ["category", _self call ["resolveCategory", [_className]]], + ["damage", _damage], + ["fuel", _fuel], + ["health", _self call ["resolveHealth", [_damage, _hitPointRows]]], + ["hitPoints", _hitPointRows] + ] + }], + ["buildNearbyVehicle", compileFinal { + params [ + ["_vehicle", objNull, [objNull]], + ["_origin", [], [[]]] + ]; + + if (isNull _vehicle) exitWith { createHashMap }; + + private _className = typeOf _vehicle; + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointRows = []; + if (_rawHitPoints isEqualType [] && { count _rawHitPoints >= 3 }) then { + private _names = _rawHitPoints param [0, []]; + private _selections = _rawHitPoints param [1, []]; + private _values = _rawHitPoints param [2, []]; + private _count = count _names; + + for "_index" from 0 to (_count - 1) do { + _hitPointRows pushBack (createHashMapFromArray [ + ["name", _names param [_index, ""]], + ["selection", _selections param [_index, ""]], + ["value", _values param [_index, 0]] + ]); + }; + }; + + private _damage = damage _vehicle; + private _distance = if (_origin isEqualType [] && { count _origin >= 2 }) then { + _vehicle distance2D _origin + } else { + _vehicle distance2D player + }; + private _ownerUid = _vehicle getVariable ["forge_garage_owner_uid", ""]; + private _plate = _vehicle getVariable ["forge_garage_plate", ""]; + + createHashMapFromArray [ + ["entryKind", "nearby"], + ["netId", netId _vehicle], + ["plate", _plate], + ["classname", _className], + ["displayName", _self call ["resolveDisplayName", [_className]]], + ["picture", _self call ["resolvePicture", [_className]]], + ["category", _self call ["resolveCategory", [_className]]], + ["damage", _damage], + ["fuel", fuel _vehicle], + ["health", _self call ["resolveHealth", [_damage, _hitPointRows]]], + ["hitPoints", _hitPointRows], + ["distance", _distance], + ["ownerUid", _ownerUid], + ["isEmpty", crew _vehicle isEqualTo []] + ] + }] +]; + +GVAR(GarageCatalogService) = createHashMapObject [GVAR(GarageCatalogServiceBaseClass)]; +GVAR(GarageCatalogService) diff --git a/arma/client/addons/garage/functions/fnc_initGarageClass.sqf b/arma/client/addons/garage/functions/fnc_initClass.sqf similarity index 90% rename from arma/client/addons/garage/functions/fnc_initGarageClass.sqf rename to arma/client/addons/garage/functions/fnc_initClass.sqf index bc136e2..841550b 100644 --- a/arma/client/addons/garage/functions/fnc_initGarageClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initClass.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initGarageClass.sqf + * File: fnc_initClass.sqf * Author: IDSolutions * Date: 2025-12-17 * Last Update: 2026-02-13 @@ -18,7 +18,7 @@ * Garage class object [HASHMAP OBJECT] * * Example: - * call forge_client_garage_fnc_initGarageClass + * call forge_client_garage_fnc_initClass */ #pragma hemtt ignore_variables ["_self"] @@ -48,8 +48,8 @@ GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ ["sync", compileFinal { params [["_data", createHashMap, [createHashMap]]]; - private _garage = _self get "garage"; private _isLoaded = _self get "isLoaded"; + private _garage = createHashMap; { _garage set [_x, _y]; } forEach _data; _self set ["garage", _garage]; @@ -57,6 +57,9 @@ GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:Garage] Sync completed"; }], + ["getGarageState", compileFinal { + _self getOrDefault ["garage", createHashMap] + }], ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; diff --git a/arma/client/addons/garage/functions/fnc_initSessionService.sqf b/arma/client/addons/garage/functions/fnc_initSessionService.sqf new file mode 100644 index 0000000..7fe871b --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initSessionService.sqf @@ -0,0 +1,298 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initSessionService.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the typed garage session service responsible for resolving the + * active garage context and building the browser hydrate payload. + */ + +#pragma hemtt ignore_variables ["_self"] + +GVAR(GarageSessionServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageSessionServiceBaseClass"], + ["#create", compileFinal { + _self set ["lastContext", createHashMap]; + }], + ["#delete", compileFinal { + _self set ["lastContext", createHashMap]; + }], + ["createDefaultContext", compileFinal { + createHashMapFromArray [ + ["name", "Vehicle Garage"], + ["anchorPosition", getPosATL player], + ["sourceObject", objNull], + ["spawnHeading", getDir player], + ["spawnPosition", player getPos [8, getDir player]], + ["spawnRadius", 6], + ["nearbyRadius", 30] + ] + }], + ["scanEntryValues", compileFinal { + params [ + ["_values", [], [[]]], + ["_state", createHashMap, [createHashMap]] + ]; + + { + if (_x isEqualType "" && { (_state getOrDefault ["name", "Vehicle Garage"]) isEqualTo "Vehicle Garage" }) then { + _state set ["name", _x]; + }; + + if (_x isEqualType "") then { + private _resolvedObject = _state getOrDefault ["sourceObject", objNull]; + if (isNull _resolvedObject) then { + private _namedObject = missionNamespace getVariable [_x, objNull]; + if (!isNull _namedObject) then { + _state set ["sourceObject", _namedObject]; + }; + }; + + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo [] && { _x in allMapMarkers }) then { + _state set ["anchorPosition", markerPos _x]; + }; + + continue; + }; + + if (_x isEqualType objNull && { isNull (_state getOrDefault ["sourceObject", objNull]) }) then { + _state set ["sourceObject", _x]; + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { + _state set ["anchorPosition", getPosATL _x]; + }; + continue; + }; + + if (_x isEqualType 0 && { (_state getOrDefault ["spawnHeading", -1]) < 0 }) then { + _state set ["spawnHeading", _x]; + continue; + }; + + if (_x isEqualType [] && { count _x > 0 }) then { + if ( + { _x isEqualType 0 } count _x >= 2 && + { + ((_state getOrDefault ["offset", []]) isEqualTo []) || + ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) + } + ) then { + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { + _state set ["anchorPosition", _x]; + } else { + _state set ["offset", _x]; + }; + continue; + }; + + _self call ["scanEntryValues", [_x, _state]]; + }; + } forEach _values; + + _state + }], + ["resolveEntry", compileFinal { + params [["_entry", [], [[]]]]; + + private _state = createHashMapFromArray [ + ["name", "Vehicle Garage"], + ["anchorPosition", []], + ["sourceObject", objNull], + ["offset", []], + ["spawnHeading", -1] + ]; + + _self call ["scanEntryValues", [_entry, _state]]; + + private _anchorPosition = _state getOrDefault ["anchorPosition", []]; + private _offset = _state getOrDefault ["offset", []]; + private _spawnPosition = if (_anchorPosition isEqualTo []) then { + [] + } else { + if (_offset isEqualTo []) then { + _anchorPosition + } else { + _anchorPosition vectorAdd _offset + } + }; + + createHashMapFromArray [ + ["name", _state getOrDefault ["name", "Vehicle Garage"]], + ["anchorPosition", _anchorPosition], + ["sourceObject", _state getOrDefault ["sourceObject", objNull]], + ["spawnHeading", _state getOrDefault ["spawnHeading", -1]], + ["spawnPosition", _spawnPosition] + ] + }], + ["resolveContext", compileFinal { + private _context = _self call ["createDefaultContext", []]; + private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData); + if !(_locations isEqualType []) exitWith { + _self set ["lastContext", _context]; + _context + }; + + private _nearestEntry = []; + private _nearestDistance = 1e10; + + { + private _entry = _self call ["resolveEntry", [_x]]; + private _anchorPosition = _entry getOrDefault ["anchorPosition", []]; + if (_anchorPosition isEqualTo []) then { + continue; + }; + + private _distance = player distance2D _anchorPosition; + if (_distance < _nearestDistance) then { + _nearestDistance = _distance; + _nearestEntry = _entry; + }; + } forEach _locations; + + if (_nearestEntry isEqualTo []) exitWith { + _self set ["lastContext", _context]; + _context + }; + + private _anchorPosition = _nearestEntry getOrDefault ["anchorPosition", []]; + private _garageObject = _nearestEntry getOrDefault ["sourceObject", objNull]; + private _garageName = _nearestEntry getOrDefault ["name", "Vehicle Garage"]; + private _spawnHeading = _nearestEntry getOrDefault ["spawnHeading", getDir player]; + if (_spawnHeading < 0) then { + _spawnHeading = if (!isNull _garageObject) then { getDir _garageObject } else { getDir player }; + }; + + private _spawnPosition = _nearestEntry getOrDefault ["spawnPosition", []]; + if (_spawnPosition isEqualTo []) then { + _spawnPosition = if (_anchorPosition isEqualTo []) then { + player getPos [8, _spawnHeading] + } else { + _anchorPosition + }; + }; + + _context set ["name", _garageName]; + _context set ["anchorPosition", _anchorPosition]; + _context set ["sourceObject", _garageObject]; + _context set ["spawnHeading", _spawnHeading]; + _context set ["spawnPosition", _spawnPosition]; + + _self set ["lastContext", _context]; + _context + }], + ["getContext", compileFinal { + _self call ["resolveContext", []] + }], + ["buildPayload", compileFinal { + private _context = _self call ["getContext", []]; + private _garageMap = if (isNil QGVAR(GarageClass)) then { + createHashMap + } else { + GVAR(GarageClass) call ["getGarageState", []] + }; + + private _anchorPosition = _context getOrDefault ["anchorPosition", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _nearbyRadius = _context getOrDefault ["nearbyRadius", 30]; + private _nearbyOrigin = [_anchorPosition, _spawnPosition] select (_anchorPosition isEqualTo []); + + private _storedVehicles = []; + private _nearbyVehicles = []; + private _nearbyEntities = []; + private _candidateVehicles = []; + + { + _candidateVehicles pushBackUnique _x; + } forEach (_nearbyOrigin nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach ((getPosATL player) nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach (nearestObjects [_nearbyOrigin, ["AllVehicles"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach (nearestObjects [getPosATL player, ["AllVehicles"], _nearbyRadius]); + + { + if (isNull _x) then { + continue; + }; + + if (_x isKindOf "CAManBase") then { + continue; + }; + + if !( + _x isKindOf "Car" || + _x isKindOf "Tank" || + _x isKindOf "Air" || + _x isKindOf "Ship" + ) then { + continue; + }; + + _nearbyEntities pushBackUnique _x; + } forEach _candidateVehicles; + + { + _storedVehicles pushBack ( + GVAR(GarageCatalogService) call ["buildStoredVehicle", [_x, _y]] + ); + } forEach _garageMap; + + private _storedVehiclePairs = _storedVehicles apply { + [toLowerANSI (_x getOrDefault ["displayName", ""]), _x] + }; + _storedVehiclePairs sort true; + _storedVehicles = _storedVehiclePairs apply { _x param [1, createHashMap] }; + + { + if (isNull _x) then { + continue; + }; + + private _builtVehicle = GVAR(GarageCatalogService) call ["buildNearbyVehicle", [_x, _nearbyOrigin]]; + if (_builtVehicle isEqualTo createHashMap) then { + continue; + }; + + _nearbyVehicles pushBack _builtVehicle; + } forEach _nearbyEntities; + + private _nearbyVehiclePairs = _nearbyVehicles apply { + [_x getOrDefault ["distance", 0], _x] + }; + _nearbyVehiclePairs sort true; + _nearbyVehicles = _nearbyVehiclePairs apply { _x param [1, createHashMap] }; + + private _spawnBlocked = ( + (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]) + + (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]) + ) isNotEqualTo []; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["garageName", _context getOrDefault ["name", "Vehicle Garage"]], + ["capacityUsed", count _storedVehicles], + ["capacityMax", 5], + ["nearbyCount", count _nearbyVehicles], + ["spawnBlocked", _spawnBlocked], + ["spawnStatus", ["Ready", "Blocked"] select _spawnBlocked] + ]], + ["garage", createHashMapFromArray [ + ["vehicles", _storedVehicles] + ]], + ["nearby", createHashMapFromArray [ + ["vehicles", _nearbyVehicles] + ]] + ] + }] +]; + +GVAR(GarageSessionService) = createHashMapObject [GVAR(GarageSessionServiceBaseClass)]; +GVAR(GarageSessionService) diff --git a/arma/client/addons/garage/functions/fnc_initUIBridge.sqf b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..ec15d50 --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf @@ -0,0 +1,205 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the garage UI bridge for browser control state and retrieve/store actions. + */ + +#pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + +GVAR(GarageUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], + ["#type", "GarageUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["pendingStoreVehicle", objNull]; + _self set ["pendingRetrieve", createHashMap]; + }], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable ["RscGarage", displayNull]; + if (isNull _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull + }; + + private _control = _display displayCtrl 1006; + _self call ["setActiveBrowserControl", [_control]]; + _control + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + + _self call ["flushPendingEvents", []]; + _self call ["sendEvent", ["garage::hydrate", GVAR(GarageSessionService) call ["buildPayload", []], _control]]; + }], + ["refreshGarage", compileFinal { + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { false }; + + _self call ["sendEvent", ["garage::sync", GVAR(GarageSessionService) call ["buildPayload", []], _control]] + }], + ["handleRetrieveRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _plate = _data getOrDefault ["plate", ""]; + if (_plate isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Select a stored vehicle to retrieve."] + ]]]; + }; + + private _garageMap = if (isNil QGVAR(GarageClass)) then { + createHashMap + } else { + GVAR(GarageClass) call ["getGarageState", []] + }; + private _vehicleData = _garageMap getOrDefault [_plate, createHashMap]; + if (_vehicleData isEqualTo createHashMap) exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Stored vehicle record could not be found."] + ]]]; + }; + + private _context = GVAR(GarageSessionService) call ["getContext", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnHeading = _context getOrDefault ["spawnHeading", getDir player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _blockingVehicles = []; + { + _blockingVehicles pushBackUnique _x; + } forEach (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]); + { + _blockingVehicles pushBackUnique _x; + } forEach (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]); + if (_blockingVehicles isNotEqualTo []) exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "The garage spawn area is blocked."] + ]]]; + }; + + private _className = _vehicleData getOrDefault ["classname", ""]; + if (_className isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Stored vehicle record is missing a classname."] + ]]]; + }; + + private _vehicle = createVehicle [_className, _spawnPosition, [], 0, "CAN_COLLIDE"]; + _vehicle setDir _spawnHeading; + _vehicle setFuel (_vehicleData getOrDefault ["fuel", 0]); + _vehicle setDamage (_vehicleData getOrDefault ["damage", 0]); + + private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; + private _hitPointNames = _hitPoints getOrDefault ["names", []]; + private _hitPointValues = _hitPoints getOrDefault ["values", []]; + for "_index" from 0 to ((count _hitPointNames) - 1) do { + _vehicle setHitPointDamage [_hitPointNames param [_index, ""], _hitPointValues param [_index, 0]]; + }; + + _vehicle setVariable ["forge_garage_plate", _plate, true]; + _vehicle setVariable ["forge_garage_owner_uid", getPlayerUID player, true]; + + _self set ["pendingRetrieve", createHashMapFromArray [ + ["plate", _plate], + ["vehicle", _vehicle] + ]]; + + [SRPC(garage,requestRetrieveVehicle), [getPlayerUID player, _plate]] call CFUNC(serverEvent); + }], + ["handleStoreRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _netId = _data getOrDefault ["netId", ""]; + if (_netId isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "Select a nearby vehicle to store."] + ]]]; + }; + + private _vehicle = objectFromNetId _netId; + if (isNull _vehicle) exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "The selected vehicle is no longer available."] + ]]]; + }; + + if (crew _vehicle isNotEqualTo []) exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "All crew must exit the vehicle before storing it."] + ]]]; + }; + + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointsJson = toJSON (createHashMapFromArray [ + ["names", _rawHitPoints param [0, []]], + ["selections", _rawHitPoints param [1, []]], + ["values", _rawHitPoints param [2, []]] + ]); + + _self set ["pendingStoreVehicle", _vehicle]; + [SRPC(garage,requestStoreVehicle), [ + getPlayerUID player, + typeOf _vehicle, + fuel _vehicle, + damage _vehicle, + _hitPointsJson + ]] call CFUNC(serverEvent); + }], + ["handleActionResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _action = _payload getOrDefault ["action", ""]; + private _success = _payload getOrDefault ["success", false]; + private _message = _payload getOrDefault ["message", "Garage action failed."]; + + switch (_action) do { + case "retrieve": { + private _pendingRetrieve = _self getOrDefault ["pendingRetrieve", createHashMap]; + private _vehicle = _pendingRetrieve getOrDefault ["vehicle", objNull]; + + if (!_success && { !isNull _vehicle }) then { + deleteVehicle _vehicle; + }; + + _self set ["pendingRetrieve", createHashMap]; + _self call ["sendEvent", [[ + "garage::retrieve::failure", + "garage::retrieve::success" + ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + case "store": { + private _vehicle = _self getOrDefault ["pendingStoreVehicle", objNull]; + + if (_success && { !isNull _vehicle }) then { + deleteVehicle _vehicle; + }; + + _self set ["pendingStoreVehicle", objNull]; + _self call ["sendEvent", [[ + "garage::store::failure", + "garage::store::success" + ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + }; + + [] spawn { + sleep 0.05; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + }] +]; + +GVAR(GarageUIBridge) = createHashMapObject [GVAR(GarageUIBridgeBaseClass)]; +GVAR(GarageUIBridge) diff --git a/arma/client/addons/garage/functions/fnc_openUI.sqf b/arma/client/addons/garage/functions/fnc_openUI.sqf index 84678e0..85e31bd 100644 --- a/arma/client/addons/garage/functions/fnc_openUI.sqf +++ b/arma/client/addons/garage/functions/fnc_openUI.sqf @@ -19,3 +19,20 @@ * Example: * call forge_client_garage_fnc_openUI; */ + +private _display = createDialog ["RscGarage", true]; +private _ctrl = _display displayCtrl 1006; + +_ctrl ctrlAddEventHandler ["JSDialog", { + params ["_control", "_isConfirmDialog", "_message"]; + + [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); +}]; + +if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["setActiveBrowserControl", [_ctrl]]; +}; + +_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)]; + +true; diff --git a/arma/client/addons/garage/ui/RscGarage.hpp b/arma/client/addons/garage/ui/RscGarage.hpp index 2507825..e6f392d 100644 --- a/arma/client/addons/garage/ui/RscGarage.hpp +++ b/arma/client/addons/garage/ui/RscGarage.hpp @@ -1,5 +1,5 @@ class RscGarage { - idd = 1000; + idd = 1005; fadeIn = 0; fadeOut = 0; duration = 1e011; @@ -10,7 +10,7 @@ class RscGarage { class controls { class IFrame: RscText { type = 106; - idc = 1001; + idc = 1006; x = "safeZoneXAbs"; y = "safeZoneY"; w = "safeZoneWAbs"; diff --git a/arma/client/addons/garage/ui/_site/garage-ui.css b/arma/client/addons/garage/ui/_site/garage-ui.css new file mode 100644 index 0000000..27a99d9 --- /dev/null +++ b/arma/client/addons/garage/ui/_site/garage-ui.css @@ -0,0 +1,574 @@ +/* Generated by tools/build-webui.mjs for Garage UI styles. Do not edit directly. */ +:root { + --garage-shell-bg: #e4e3df; + --garage-surface: #f5f3ef; + --garage-surface-alt: #ece8e2; + --garage-border: rgba(74, 91, 110, 0.2); + --garage-border-strong: rgba(20, 46, 79, 0.18); + --garage-text-main: #1f2d3d; + --garage-text-muted: #6a7787; + --garage-text-subtle: #8792a0; + --garage-accent: #12365d; + --garage-accent-soft: #dbe7f3; + --garage-accent-line: rgba(18, 54, 93, 0.12); + --garage-warning: #8f5f26; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--garage-text-main); + background: var(--garage-shell-bg); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.72; +} + +:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.35); + outline-offset: 2px; +} + +#app { + width: 100%; + height: 100%; +} + +.garage-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--garage-shell-bg); +} + +.garage-layout { + flex: 1; + min-height: 0; + width: min(100%, 1613px); + margin: 0 auto; + padding: 1.25rem; + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + gap: 1.25rem; +} + +.garage-sidebar, +.garage-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.garage-main { + overflow: hidden; +} + +.garage-module, +.garage-panel, +.garage-card { + background: linear-gradient( + 180deg, + var(--garage-surface) 0%, + var(--garage-surface-alt) 100% + ); + border: 1px solid var(--garage-border); + border-radius: 1.35rem; +} + +.garage-module, +.garage-card { + padding: 1rem; +} + +.garage-module { + display: grid; + gap: 0.85rem; + align-content: start; +} + +.garage-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.garage-panel-header, +.garage-module-header, +.garage-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.garage-panel-header { + padding: 1rem 1rem 0; +} + +.garage-module-header { + align-items: flex-start; +} + +.garage-panel-intro { + padding: 0 1rem 1rem; + border-bottom: 1px solid var(--garage-accent-line); +} + +.garage-dashboard { + flex: 1; + min-height: 0; + padding: 1rem; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + align-items: stretch; +} + +.garage-list-card, +.garage-detail-card { + min-height: 0; + display: flex; + flex-direction: column; +} + +.garage-detail-card { + grid-column: 1 / -1; +} + +.garage-scroll-body { + flex: 1; + min-height: 20rem; + max-height: 24rem; + overflow: auto; + display: grid; + gap: 0.8rem; + padding-right: 0.2rem; +} + +.garage-detail-body { + padding-top: 0.95rem; +} + +.garage-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr); + gap: 1rem; +} + +.garage-detail-meta, +.garage-summary-grid, +.garage-search-actions, +.garage-category-grid, +.garage-action-row, +.garage-inline-meters, +.garage-hitpoint-grid, +.garage-footer { + display: grid; + gap: 0.75rem; +} + +.garage-detail-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +.garage-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.garage-summary-grid > :last-child { + grid-column: 1 / -1; +} + +.garage-search-actions, +.garage-action-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-category-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-footer { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 0.95rem 1.25rem 1.15rem; + border-top: 1px solid rgb(18 54 93 / 0.1); +} + +.garage-meter-stack { + display: grid; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.garage-eyebrow, +.garage-footer-title, +.garage-stat-label, +.garage-meter-label, +.garage-hitpoint-selection { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--garage-text-subtle); +} + +.garage-title, +.garage-section-title { + margin: 0.16rem 0 0; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--garage-text-main); +} + +.garage-title { + font-size: 1.1rem; +} + +.garage-section-title { + font-size: 1.05rem; +} + +.garage-copy, +.garage-detail-note, +.garage-empty-copy, +.garage-footer-copy, +.garage-vehicle-meta, +.garage-detail-caption { + margin: 0; + font-size: 0.92rem; + line-height: 1.48; + color: var(--garage-text-muted); +} + +.garage-pill, +.garage-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.48rem 0.8rem; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + background: var(--garage-accent-soft); + color: var(--garage-accent); +} + +.garage-badge.is-warning { + background: rgb(246 226 193 / 0.88); + color: var(--garage-warning); +} + +.garage-search-form { + display: grid; + gap: 0.75rem; +} + +.garage-search-input { + width: 100%; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.75); + color: var(--garage-text-main); +} + +.garage-stat-card { + min-width: 0; + padding: 0.85rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.garage-stat-card.is-accent { + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.92) 0%, + rgb(223 232 242 / 0.72) 100% + ); +} + +.garage-stat-card.is-danger { + background: linear-gradient( + 180deg, + rgb(254 242 242 / 0.95) 0%, + rgb(252 225 225 / 0.82) 100% + ); + border-color: rgb(220 151 151 / 0.38); +} + +.garage-stat-value { + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); + line-height: 1.3; + overflow-wrap: anywhere; + word-break: break-word; +} + +.garage-chip { + min-height: 2.6rem; + padding: 0.68rem 0.9rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); + color: var(--garage-text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-chip.is-active { + background: var(--garage-accent-soft); + color: var(--garage-accent); + border-color: rgb(18 54 93 / 0.2); +} + +.garage-vehicle-item { + width: 100%; + padding: 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + color: inherit; + text-align: left; +} + +.garage-vehicle-item.is-selected { + border-color: rgb(18 54 93 / 0.24); + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.96) 0%, + rgb(223 232 242 / 0.74) 100% + ); + box-shadow: 0 16px 26px rgb(18 54 93 / 0.08); +} + +.garage-vehicle-item-head, +.garage-meter-label-row, +.garage-subsystem-header, +.garage-hitpoint-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.garage-vehicle-copy, +.garage-hitpoint-copy, +.garage-footer-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.garage-vehicle-title, +.garage-hitpoint-name, +.garage-hitpoint-value { + font-size: 0.9rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter { + display: grid; + gap: 0.32rem; +} + +.garage-meter-track { + width: 100%; + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: rgb(18 54 93 / 0.08); +} + +.garage-meter-value { + font-size: 0.78rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter-fill { + display: block; + height: 100%; + border-radius: inherit; +} + +.garage-meter-fill.is-health { + background: linear-gradient(90deg, #2f7d5b 0%, #4eaa82 100%); +} + +.garage-meter-fill.is-fuel { + background: linear-gradient(90deg, #12365d 0%, #3c6792 100%); +} + +.garage-btn { + min-height: 2.75rem; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border-strong); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-btn-primary { + background: rgb(255 255 255 / 0.68); + color: var(--garage-accent); +} + +.garage-btn-primary:hover { + background: rgb(219 231 243 / 0.88); +} + +.garage-btn-secondary { + background: rgb(255 255 255 / 0.42); + color: var(--garage-text-muted); +} + +.garage-btn-secondary:hover { + background: rgb(255 255 255 / 0.6); + color: var(--garage-text-main); +} + +.garage-hitpoint-row { + padding: 0.72rem 0.78rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); +} + +.garage-detail-empty, +.garage-empty-state { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 100%; +} + +.garage-empty-title { + margin: 0 0 0.35rem; + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-empty-inline { + padding: 0.9rem; + border-radius: 0.85rem; + border: 1px dashed var(--garage-border); + color: var(--garage-text-muted); + background: rgb(255 255 255 / 0.36); +} + +.garage-toast-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.garage-toast { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--garage-border); + background: #fff; + box-shadow: 0 14px 28px rgb(16 34 56 / 0.14); + font-size: 0.92rem; +} + +.garage-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +.garage-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +@media (max-width: 1440px) { + .garage-layout { + grid-template-columns: 288px minmax(0, 1fr); + } + + .garage-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1120px) { + .garage-layout { + grid-template-columns: 1fr; + overflow: auto; + } + + .garage-main, + .garage-sidebar { + min-height: auto; + } + + .garage-dashboard { + grid-template-columns: 1fr; + } + + .garage-detail-card { + grid-column: auto; + } + + .garage-scroll-body { + max-height: none; + min-height: 16rem; + } + + .garage-footer { + grid-template-columns: 1fr; + } +} diff --git a/arma/client/addons/garage/ui/_site/garage-ui.js b/arma/client/addons/garage/ui/_site/garage-ui.js new file mode 100644 index 0000000..df7e4b9 --- /dev/null +++ b/arma/client/addons/garage/ui/_site/garage-ui.js @@ -0,0 +1,1291 @@ +/* Generated by tools/build-webui.mjs for Garage UI app. Do not edit directly. */ +(function () { + const runtime = window.ForgeWebUI; + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + GarageApp.runtime = runtime; + window.AppRuntime = runtime; +})(); + +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + const defaultSession = { + garageName: "Vehicle Garage", + capacityUsed: 0, + capacityMax: 5, + nearbyCount: 0, + spawnBlocked: false, + spawnStatus: "Ready", + }; + + const defaultGarage = { + vehicles: [], + }; + + const defaultNearby = { + vehicles: [], + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + GarageApp.data = { + categories: [ + { id: "all", label: "All" }, + { id: "car", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "air", label: "Air" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + session: Object.assign({}, defaultSession), + garage: Object.assign({}, defaultGarage), + nearby: Object.assign({}, defaultNearby), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.garage, + Object.assign({}, defaultGarage, payload?.garage || {}), + ); + replaceObject( + this.nearby, + Object.assign({}, defaultNearby, payload?.nearby || {}), + ); + }, + }; +})(); + +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { createSignal } = GarageApp.runtime; + + class GarageStore { + constructor() { + [this.getSelectedKind, this.setSelectedKind] = createSignal(""); + [this.getSelectedId, this.setSelectedId] = createSignal(""); + [this.getSearchQuery, this.setSearchQuery] = createSignal(""); + [this.getCategoryFilter, this.setCategoryFilter] = + createSignal("all"); + [this.getPendingAction, this.setPendingAction] = createSignal(""); + [this.getNotice, this.setNotice] = createSignal({ + type: "", + text: "", + }); + } + + getSelection() { + return { + id: this.getSelectedId(), + kind: this.getSelectedKind(), + }; + } + + clearSelection() { + this.setSelectedKind(""); + this.setSelectedId(""); + } + + select(kind, id) { + this.setSelectedKind(String(kind || "")); + this.setSelectedId(String(id || "")); + } + + startAction(action) { + this.setPendingAction(String(action || "")); + } + + finishAction() { + this.setPendingAction(""); + } + + matchesSelection(entry) { + if (!entry || typeof entry !== "object") { + return false; + } + + const selection = this.getSelection(); + if (!selection.kind || !selection.id) { + return false; + } + + if (selection.kind === "stored") { + return ( + entry.entryKind === "stored" && + String(entry.plate || "") === selection.id + ); + } + + if (selection.kind === "nearby") { + return ( + entry.entryKind === "nearby" && + String(entry.netId || "") === selection.id + ); + } + + return false; + } + + ensureSelection() { + const garageVehicles = Array.isArray( + GarageApp.data?.garage?.vehicles, + ) + ? GarageApp.data.garage.vehicles + : []; + const nearbyVehicles = Array.isArray( + GarageApp.data?.nearby?.vehicles, + ) + ? GarageApp.data.nearby.vehicles + : []; + const hasCurrentSelection = [ + ...garageVehicles, + ...nearbyVehicles, + ].some((entry) => this.matchesSelection(entry)); + + if (hasCurrentSelection) { + return; + } + + const firstStored = garageVehicles[0] || null; + if (firstStored) { + this.select("stored", firstStored.plate || ""); + return; + } + + const firstNearby = nearbyVehicles[0] || null; + if (firstNearby) { + this.select("nearby", firstNearby.netId || ""); + return; + } + + this.clearSelection(); + } + + hydrateFromPayload() { + this.finishAction(); + this.ensureSelection(); + } + } + + GarageApp.store = new GarageStore(); +})(); + +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "garage::close", + globalName: "ForgeBridge", + readyEvent: "garage::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestRefresh() { + return bridge.send("garage::refresh", {}); + } + + function requestRetrieve(payload) { + return bridge.send("garage::vehicle::retrieve::request", payload); + } + + function requestStore(payload) { + return bridge.send("garage::vehicle::store::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + function hydrate(payloadData) { + GarageApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + } + + bridge.on("garage::hydrate", hydrate); + bridge.on("garage::sync", hydrate); + + bridge.on("garage::retrieve::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle retrieved from the garage.", + ); + } + }); + + bridge.on("garage::retrieve::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to retrieve vehicle.", + ); + } + }); + + bridge.on("garage::store::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle stored in the garage.", + ); + } + }); + + bridge.on("garage::store::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to store vehicle.", + ); + } + }); + + GarageApp.bridge = { + notifyReady, + receive: bridge.receive, + requestClose, + requestRefresh, + requestRetrieve, + requestStore, + sendEvent: bridge.send, + }; +})(); + +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + + let noticeTimer = null; + + function getStoredVehicles() { + return Array.isArray(GarageApp.data?.garage?.vehicles) + ? GarageApp.data.garage.vehicles + : []; + } + + function getNearbyVehicles() { + return Array.isArray(GarageApp.data?.nearby?.vehicles) + ? GarageApp.data.nearby.vehicles + : []; + } + + function getSelectedEntry() { + const selection = store.getSelection(); + if (selection.kind === "stored") { + return ( + getStoredVehicles().find( + (vehicle) => String(vehicle.plate || "") === selection.id, + ) || null + ); + } + + if (selection.kind === "nearby") { + return ( + getNearbyVehicles().find( + (vehicle) => String(vehicle.netId || "") === selection.id, + ) || null + ); + } + + return null; + } + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ type: "", text: "" }); + noticeTimer = null; + }, 3200); + } + + function closeGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage bridge is unavailable."); + return false; + } + + function refreshGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestRefresh === "function") { + const sent = bridge.requestRefresh(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage refresh bridge is unavailable."); + return false; + } + + function applySearchQuery(value) { + store.setSearchQuery(String(value || "").trim()); + } + + function clearSearch() { + store.setSearchQuery(""); + } + + function selectCategory(categoryId) { + store.setCategoryFilter(String(categoryId || "all").trim() || "all"); + } + + function selectEntry(kind, id) { + store.select(kind, id); + } + + function requestRetrieveSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "stored") { + showNotice("error", "Select a stored vehicle to retrieve."); + return false; + } + + if (GarageApp.data?.session?.spawnBlocked) { + showNotice("error", "The garage spawn area is blocked."); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestRetrieve !== "function") { + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + store.startAction("retrieve"); + const sent = bridge.requestRetrieve({ + plate: selectedEntry.plate || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + return true; + } + + function requestStoreSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "nearby") { + showNotice("error", "Select a nearby vehicle to store."); + return false; + } + + if (selectedEntry.isEmpty === false) { + showNotice( + "error", + "All crew must exit the vehicle before storing it.", + ); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestStore !== "function") { + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + store.startAction("store"); + const sent = bridge.requestStore({ + netId: selectedEntry.netId || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + return true; + } + + GarageApp.actions = { + showNotice, + closeGarage, + refreshGarage, + applySearchQuery, + clearSearch, + selectCategory, + selectEntry, + getSelectedEntry, + requestRetrieveSelected, + requestStoreSelected, + }; +})(); + +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { h } = GarageApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = GarageApp.store; + const actions = GarageApp.actions; + const { categories, garage, nearby, session } = GarageApp.data; + + function q(query, values) { + const needle = String(query || "") + .trim() + .toLowerCase(); + if (!needle) { + return true; + } + + return values.some((value) => + String(value || "") + .toLowerCase() + .includes(needle), + ); + } + + function pct(value) { + return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 100))); + } + + function categoryLabel(category) { + const match = categories.find( + (entry) => entry.id === String(category || "other").toLowerCase(), + ); + return match ? match.label : "Other"; + } + + function distanceLabel(value) { + return `${Math.round(Number(value || 0))} m`; + } + + function plateLabel(value) { + return String(value || "").trim() || "Untracked"; + } + + function statusLabel(vehicle) { + if (!vehicle) { + return "-"; + } + + if (vehicle.entryKind === "stored") { + return "Stored"; + } + + return vehicle.isEmpty === false ? "Crewed" : "Ready"; + } + + function normalizeHitPointLabel(value) { + return String(value || "") + .replace(/^Hit/i, "") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); + } + + function sameEntry(left, right) { + if (!left || !right) { + return false; + } + + return ( + String(left.entryKind || "") === String(right.entryKind || "") && + String(left.plate || "") === String(right.plate || "") && + String(left.netId || "") === String(right.netId || "") + ); + } + + function selectedEntry(state) { + if (state.selectedKind === "stored") { + return ( + (garage.vehicles || []).find( + (vehicle) => + String(vehicle.plate || "") === state.selectedId, + ) || null + ); + } + + if (state.selectedKind === "nearby") { + return ( + (nearby.vehicles || []).find( + (vehicle) => + String(vehicle.netId || "") === state.selectedId, + ) || null + ); + } + + return null; + } + + function visibleVehicles(vehicles, state) { + return (vehicles || []).filter((vehicle) => { + if ( + state.categoryFilter !== "all" && + String(vehicle.category || "").toLowerCase() !== + state.categoryFilter + ) { + return false; + } + + return q(state.searchQuery, [ + vehicle.displayName, + vehicle.classname, + vehicle.plate, + vehicle.netId, + vehicle.category, + ]); + }); + } + + function stat(label, value, tone = "") { + return h( + "div", + { + className: tone + ? `garage-stat-card is-${tone}` + : "garage-stat-card", + }, + h("span", { className: "garage-stat-label" }, label), + h("span", { className: "garage-stat-value" }, value), + ); + } + + function meter(label, percent, tone) { + return h( + "div", + { className: "garage-meter" }, + h( + "div", + { className: "garage-meter-label-row" }, + h("span", { className: "garage-meter-label" }, label), + h("span", { className: "garage-meter-value" }, `${percent}%`), + ), + h( + "div", + { className: "garage-meter-track" }, + h("span", { + className: `garage-meter-fill is-${tone}`, + style: { width: `${percent}%` }, + }), + ), + ); + } + + function vehicleItem(vehicle, currentSelection) { + const id = + vehicle.entryKind === "stored" + ? String(vehicle.plate || "") + : String(vehicle.netId || ""); + const isNearby = vehicle.entryKind === "nearby"; + + return h( + "button", + { + type: "button", + className: sameEntry(vehicle, currentSelection) + ? "garage-vehicle-item is-selected" + : "garage-vehicle-item", + onClick: () => actions.selectEntry(vehicle.entryKind, id), + }, + h( + "div", + { className: "garage-vehicle-item-head" }, + h( + "div", + { className: "garage-vehicle-copy" }, + h( + "span", + { className: "garage-vehicle-title" }, + vehicle.displayName || vehicle.classname || "Vehicle", + ), + h( + "span", + { className: "garage-vehicle-meta" }, + isNearby + ? `Nearby ${distanceLabel(vehicle.distance)}` + : `Plate ${plateLabel(vehicle.plate)}`, + ), + ), + h( + "span", + { + className: + isNearby && vehicle.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isNearby + ? vehicle.isEmpty === false + ? "Crewed" + : "Empty" + : categoryLabel(vehicle.category), + ), + ), + h( + "div", + { className: "garage-inline-meters" }, + meter("Health", pct(vehicle.health), "health"), + meter("Fuel", pct(vehicle.fuel), "fuel"), + ), + ); + } + + function vehicleList(title, eyebrow, scrollId, vehicles, currentSelection) { + return h( + "section", + { className: "garage-card garage-list-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, eyebrow), + h("h2", { className: "garage-section-title" }, title), + ), + h( + "span", + { className: "garage-pill" }, + `${vehicles.length} ${vehicles.length === 1 ? "Vehicle" : "Vehicles"}`, + ), + ), + h( + "div", + { + className: "garage-card-body garage-scroll-body", + "data-preserve-scroll-id": scrollId, + }, + vehicles.length > 0 + ? vehicles.map((vehicle) => + vehicleItem(vehicle, currentSelection), + ) + : h( + "div", + { className: "garage-empty-state" }, + h( + "h3", + { className: "garage-empty-title" }, + "No matching vehicles", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Adjust the current search or category filter to view more records.", + ), + ), + ), + ); + } + + function hitPointRows(hitPoints) { + const rows = (Array.isArray(hitPoints) ? hitPoints : []) + .slice() + .sort( + (left, right) => + Number(right.value || 0) - Number(left.value || 0), + ) + .slice(0, 6) + .filter((row) => Number(row.value || 0) > 0); + + if (rows.length === 0) { + return h( + "div", + { className: "garage-empty-inline" }, + "No subsystem damage reported.", + ); + } + + return h( + "div", + { className: "garage-hitpoint-grid" }, + rows.map((row) => + h( + "div", + { className: "garage-hitpoint-row" }, + h( + "div", + { className: "garage-hitpoint-copy" }, + h( + "span", + { className: "garage-hitpoint-name" }, + normalizeHitPointLabel(row.name) || "Subsystem", + ), + row.selection + ? h( + "span", + { className: "garage-hitpoint-selection" }, + row.selection, + ) + : null, + ), + h( + "span", + { className: "garage-hitpoint-value" }, + `${Math.round(Number(row.value || 0) * 100)}%`, + ), + ), + ), + ); + } + + function detailPanel(currentSelection, state) { + if (!currentSelection) { + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, "Selection"), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Detail", + ), + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-empty" }, + h( + "h3", + { className: "garage-empty-title" }, + "Select a vehicle", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Choose a stored record to retrieve or a nearby vehicle to store.", + ), + ), + ); + } + + const isStored = currentSelection.entryKind === "stored"; + const pendingAction = String(state.pendingAction || ""); + const isBusy = + pendingAction === "retrieve" || pendingAction === "store"; + const canRetrieve = isStored && !session.spawnBlocked && !isBusy; + const canStore = + !isStored && currentSelection.isEmpty !== false && !isBusy; + + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + isStored ? "Stored Record" : "Nearby Vehicle", + ), + h( + "h2", + { className: "garage-section-title" }, + currentSelection.displayName || + currentSelection.classname || + "Vehicle", + ), + ), + h( + "span", + { + className: + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isStored + ? `Plate ${plateLabel(currentSelection.plate)}` + : currentSelection.isEmpty === false + ? "Crewed" + : "Ready", + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-body" }, + h( + "div", + { className: "garage-detail-grid" }, + h( + "div", + { className: "garage-detail-copy" }, + h( + "div", + { className: "garage-detail-meta" }, + stat( + "Category", + categoryLabel(currentSelection.category), + ), + stat( + "Status", + statusLabel(currentSelection), + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "danger" + : "", + ), + stat( + isStored ? "Record" : "Distance", + isStored + ? plateLabel(currentSelection.plate) + : distanceLabel(currentSelection.distance), + isStored ? "" : "accent", + ), + ), + h( + "div", + { className: "garage-meter-stack" }, + meter( + "Health", + pct(currentSelection.health), + "health", + ), + meter("Fuel", pct(currentSelection.fuel), "fuel"), + ), + h( + "div", + { className: "garage-action-row" }, + isStored + ? h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canRetrieve, + onClick: () => + actions.requestRetrieveSelected(), + }, + pendingAction === "retrieve" + ? "Retrieving..." + : "Retrieve Vehicle", + ) + : h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canStore, + onClick: () => + actions.requestStoreSelected(), + }, + pendingAction === "store" + ? "Storing..." + : "Store Vehicle", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: isBusy, + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "p", + { className: "garage-detail-note" }, + isStored + ? session.spawnBlocked + ? "The garage spawn lane is currently blocked." + : "Retrieve this stored vehicle into the active spawn lane." + : currentSelection.isEmpty === false + ? "Only empty nearby vehicles can be stored." + : "Store this nearby vehicle back into persistent garage storage.", + ), + ), + h( + "div", + { className: "garage-detail-subsystems" }, + h( + "div", + { className: "garage-subsystem-header" }, + h( + "span", + { className: "garage-eyebrow" }, + "Subsystems", + ), + h( + "span", + { className: "garage-detail-caption" }, + "Highest damage first", + ), + ), + hitPointRows(currentSelection.hitPoints), + ), + ), + ), + ); + } + + GarageApp.components = GarageApp.components || {}; + GarageApp.components.App = function App() { + const state = { + categoryFilter: store.getCategoryFilter(), + notice: store.getNotice(), + pendingAction: store.getPendingAction(), + searchQuery: store.getSearchQuery(), + selectedId: store.getSelectedId(), + selectedKind: store.getSelectedKind(), + }; + const currentSelection = selectedEntry(state); + const storedVehicles = visibleVehicles(garage.vehicles || [], state); + const nearbyVehicles = visibleVehicles(nearby.vehicles || [], state); + const searchLabel = state.searchQuery + ? `Search: ${state.searchQuery}` + : "Live"; + + return h( + "div", + { className: "garage-shell" }, + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Vehicle Garage", + onClose: () => actions.closeGarage(), + closeLabel: "Close garage interface", + }), + state.notice.text + ? h( + "div", + { className: "garage-toast-stack" }, + h( + "div", + { + className: + state.notice.type === "error" + ? "garage-toast is-error" + : "garage-toast is-success", + }, + state.notice.text, + ), + ) + : null, + h( + "div", + { className: "garage-layout" }, + h( + "aside", + { className: "garage-sidebar" }, + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Search", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Records", + ), + ), + h( + "span", + { className: "garage-pill" }, + searchLabel, + ), + ), + h( + "div", + { className: "garage-search-form" }, + h("input", { + id: "garage-search-input", + type: "text", + className: "garage-search-input", + placeholder: + "Search by name, plate, or category", + value: state.searchQuery, + }), + h( + "div", + { className: "garage-search-actions" }, + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + onClick: () => + actions.applySearchQuery( + document.getElementById( + "garage-search-input", + )?.value || "", + ), + }, + "Apply Search", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + onClick: () => actions.clearSearch(), + }, + "Clear", + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Filter", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Categories", + ), + ), + ), + h( + "div", + { className: "garage-category-grid" }, + categories.map((category) => + h( + "button", + { + type: "button", + className: + state.categoryFilter === category.id + ? "garage-chip is-active" + : "garage-chip", + onClick: () => + actions.selectCategory(category.id), + }, + category.label, + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Status", + ), + h( + "h2", + { className: "garage-section-title" }, + "Garage Summary", + ), + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: Boolean(state.pendingAction), + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "div", + { className: "garage-summary-grid" }, + stat( + "Stored", + `${session.capacityUsed}/${session.capacityMax}`, + ), + stat("Nearby", session.nearbyCount, "accent"), + stat( + "Spawn Lane", + session.spawnStatus, + session.spawnBlocked ? "danger" : "", + ), + ), + ), + ), + h( + "main", + { className: "garage-main" }, + h( + "section", + { className: "garage-panel" }, + h( + "div", + { className: "garage-panel-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Operations Bay", + ), + h( + "h1", + { className: "garage-title" }, + session.garageName || "Vehicle Garage", + ), + ), + h( + "span", + { className: "garage-pill" }, + `${session.capacityUsed}/${session.capacityMax} Stored`, + ), + ), + h( + "div", + { className: "garage-panel-intro" }, + h( + "p", + { className: "garage-copy" }, + "Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.", + ), + ), + h( + "div", + { className: "garage-dashboard" }, + vehicleList( + "Stored Vehicles", + "Persistent Records", + "garage-stored-list", + storedVehicles, + currentSelection, + ), + vehicleList( + "Nearby Vehicles", + "Store Window", + "garage-nearby-list", + nearbyVehicles, + currentSelection, + ), + detailPanel(currentSelection, state), + ), + ), + ), + ), + h( + "footer", + { className: "garage-footer" }, + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Storage Capacity", + ), + h( + "span", + { className: "garage-footer-copy" }, + `${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`, + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Retrieval Window", + ), + h( + "span", + { className: "garage-footer-copy" }, + session.spawnBlocked + ? "Spawn lane is blocked. Clear the bay before retrieving another vehicle." + : "Spawn lane is clear. Stored vehicles can be retrieved immediately.", + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Store Rules", + ), + h( + "span", + { className: "garage-footer-copy" }, + "Only nearby empty vehicles can be stored. Nearby count updates from the live world state.", + ), + ), + ), + ); + }; +})(); + +(function () { + const ForgeWebUI = window.ForgeWebUI; + const GarageApp = window.GarageApp; + const app = ForgeWebUI.createApp({ + name: "garage", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => GarageApp.components.App(), { + preserveScroll: true, + }); + + if (GarageApp.bridge) { + GarageApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/garage/ui/_site/index.html b/arma/client/addons/garage/ui/_site/index.html index ccd5165..187d47a 100644 --- a/arma/client/addons/garage/ui/_site/index.html +++ b/arma/client/addons/garage/ui/_site/index.html @@ -1,261 +1,64 @@ + - Vehicle Garage - - + FORGE Vehicle Garage -
- -
- -
-

Vehicle Garage

-

Vehicle Management System

-
-
-
- Stored - 12 -
-
- Active - 2 -
-
- Capacity - 20 -
-
-
- -
-
- - -
- -
-
-

Filters

-
-
- -
-

Status

-
- - - -
-
- - -
-

Vehicle Type

-
- - - - - -
-
- - -
-

Search

- -
-
-
- - -
-
-

Your Vehicles

-
-
-
- -
-
-
- - -
-
-

Vehicle Details

-
-
-
-
🚗
-

Select a vehicle to view details

-
- - -
-
-
-
- - +
diff --git a/arma/client/addons/garage/ui/_site/script.js b/arma/client/addons/garage/ui/_site/script.js deleted file mode 100644 index 8fc68f6..0000000 --- a/arma/client/addons/garage/ui/_site/script.js +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Vehicle Garage Interface - * Handles vehicle management with spawn and store actions - */ - -// Mock data - sample vehicles -const mockData = { - vehicles: [ - // Cars - { - id: 1, - name: "Sedan", - type: "car", - icon: "🚗", - status: "stored", - condition: 95, - fuel: 80, - location: "Garage A", - seats: 4, - speed: "180 km/h", - cargo: "200 kg", - }, - { - id: 2, - name: "Sports Car", - type: "car", - icon: "🏎️", - status: "stored", - condition: 100, - fuel: 100, - location: "Garage A", - seats: 2, - speed: "250 km/h", - cargo: "50 kg", - }, - { - id: 3, - name: "SUV", - type: "car", - icon: "🚙", - status: "active", - condition: 85, - fuel: 60, - location: "In Use", - seats: 6, - speed: "160 km/h", - cargo: "400 kg", - }, - { - id: 4, - name: "Hatchback", - type: "car", - icon: "🚗", - status: "stored", - condition: 90, - fuel: 75, - location: "Garage B", - seats: 4, - speed: "170 km/h", - cargo: "250 kg", - }, - - // Trucks - { - id: 5, - name: "Pickup Truck", - type: "truck", - icon: "🚛", - status: "stored", - condition: 88, - fuel: 70, - location: "Garage A", - seats: 2, - speed: "140 km/h", - cargo: "800 kg", - }, - { - id: 6, - name: "Delivery Van", - type: "truck", - icon: "🚚", - status: "stored", - condition: 92, - fuel: 85, - location: "Garage B", - seats: 3, - speed: "130 km/h", - cargo: "1200 kg", - }, - { - id: 7, - name: "Heavy Truck", - type: "truck", - icon: "🚛", - status: "active", - condition: 75, - fuel: 50, - location: "In Use", - seats: 2, - speed: "120 km/h", - cargo: "2000 kg", - }, - { - id: 8, - name: "Box Truck", - type: "truck", - icon: "📦", - status: "stored", - condition: 80, - fuel: 65, - location: "Garage A", - seats: 3, - speed: "110 km/h", - cargo: "1500 kg", - }, - - // Aircraft - { - id: 9, - name: "Helicopter", - type: "air", - icon: "🚁", - status: "stored", - condition: 95, - fuel: 90, - location: "Helipad", - seats: 6, - speed: "280 km/h", - cargo: "500 kg", - }, - { - id: 10, - name: "Light Plane", - type: "air", - icon: "✈️", - status: "stored", - condition: 100, - fuel: 100, - location: "Hangar", - seats: 4, - speed: "320 km/h", - cargo: "300 kg", - }, - - // Boats - { - id: 11, - name: "Speedboat", - type: "sea", - icon: "🚤", - status: "stored", - condition: 93, - fuel: 80, - location: "Marina", - seats: 4, - speed: "100 km/h", - cargo: "150 kg", - }, - { - id: 12, - name: "Yacht", - type: "sea", - icon: "🛥️", - status: "stored", - condition: 98, - fuel: 95, - location: "Marina", - seats: 12, - speed: "60 km/h", - cargo: "800 kg", - }, - ], -}; - -// State -let selectedVehicle = null; -let statusFilter = "all"; -let typeFilter = "all"; -let searchQuery = ""; - -// Icons by type -const typeIcons = { - car: "🚗", - truck: "🚛", - air: "🚁", - sea: "🚤", -}; - -// Initialize -function initGarage() { - console.log("Garage interface initializing..."); - - setupEventHandlers(); - renderVehicles(); - updateStats(); - - console.log("Garage interface initialized"); -} - -// Event Handlers -function setupEventHandlers() { - // Close button - const closeBtn = document.querySelector(".close-btn"); - if (closeBtn) { - closeBtn.addEventListener("click", () => { - console.log("Closing garage..."); - sendEvent("garage::close", {}); - }); - } - - // Status filters - const filterBtns = document.querySelectorAll(".filter-btn"); - filterBtns.forEach((btn) => { - btn.addEventListener("click", () => { - filterBtns.forEach((b) => b.classList.remove("active")); - btn.classList.add("active"); - statusFilter = btn.dataset.filter; - renderVehicles(); - }); - }); - - // Type filters - const typeItems = document.querySelectorAll(".type-item"); - typeItems.forEach((item) => { - item.addEventListener("click", () => { - typeItems.forEach((i) => i.classList.remove("active")); - item.classList.add("active"); - typeFilter = item.dataset.type; - renderVehicles(); - }); - }); - - // Search - const searchInput = document.getElementById("searchInput"); - if (searchInput) { - searchInput.addEventListener("input", (e) => { - searchQuery = e.target.value.toLowerCase(); - renderVehicles(); - }); - } - - // Spawn button - const spawnBtn = document.getElementById("spawnBtn"); - if (spawnBtn) { - spawnBtn.addEventListener("click", () => { - if (selectedVehicle) { - spawnVehicle(selectedVehicle); - } - }); - } - - // Store button - const storeBtn = document.getElementById("storeBtn"); - if (storeBtn) { - storeBtn.addEventListener("click", () => { - if (selectedVehicle) { - storeVehicle(selectedVehicle); - } - }); - } -} - -// Render vehicles -function renderVehicles() { - const vehiclesGrid = document.getElementById("vehiclesGrid"); - if (!vehiclesGrid) return; - - vehiclesGrid.innerHTML = ""; - - // Filter vehicles - let filtered = mockData.vehicles; - - // Status filter - if (statusFilter !== "all") { - filtered = filtered.filter((v) => v.status === statusFilter); - } - - // Type filter - if (typeFilter !== "all") { - filtered = filtered.filter((v) => v.type === typeFilter); - } - - // Search filter - if (searchQuery) { - filtered = filtered.filter( - (v) => - v.name.toLowerCase().includes(searchQuery) || - v.type.toLowerCase().includes(searchQuery), - ); - } - - // Render vehicles - filtered.forEach((vehicle) => { - const card = document.createElement("div"); - card.className = "vehicle-card"; - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - card.classList.add("selected"); - } - - card.innerHTML = ` -
${vehicle.icon}
-
${vehicle.name}
-
${vehicle.type}
-
${vehicle.status}
- `; - - card.addEventListener("click", () => selectVehicle(vehicle)); - vehiclesGrid.appendChild(card); - }); - - console.log(`Rendered ${filtered.length} vehicles`); -} - -// Select vehicle -function selectVehicle(vehicle) { - selectedVehicle = vehicle; - - // Update selected state in grid - document.querySelectorAll(".vehicle-card").forEach((card) => { - card.classList.remove("selected"); - }); - event.currentTarget.classList.add("selected"); - - // Show details - showVehicleDetails(vehicle); -} - -// Show vehicle details -function showVehicleDetails(vehicle) { - const noSelection = document.getElementById("noSelection"); - const vehicleDetails = document.getElementById("vehicleDetails"); - const spawnBtn = document.getElementById("spawnBtn"); - const storeBtn = document.getElementById("storeBtn"); - - if (noSelection) noSelection.style.display = "none"; - if (vehicleDetails) vehicleDetails.style.display = "flex"; - - // Update details - document.getElementById("detailIcon").textContent = vehicle.icon; - document.getElementById("detailName").textContent = vehicle.name; - document.getElementById("detailType").textContent = vehicle.type; - document.getElementById("detailStatus").textContent = vehicle.status; - document.getElementById("detailCondition").textContent = - `${vehicle.condition}%`; - document.getElementById("detailFuel").textContent = `${vehicle.fuel}%`; - document.getElementById("detailLocation").textContent = vehicle.location; - document.getElementById("detailSeats").textContent = vehicle.seats; - document.getElementById("detailSpeed").textContent = vehicle.speed; - document.getElementById("detailCargo").textContent = vehicle.cargo; - - // Show/hide action buttons based on status - if (vehicle.status === "stored") { - spawnBtn.style.display = "flex"; - storeBtn.style.display = "none"; - } else { - spawnBtn.style.display = "none"; - storeBtn.style.display = "flex"; - } -} - -// Spawn vehicle -function spawnVehicle(vehicle) { - console.log("Spawning vehicle:", vehicle.name); - - // Update local state - vehicle.status = "active"; - vehicle.location = "In Use"; - - sendEvent("garage::spawn", { - vehicleId: vehicle.id, - vehicleName: vehicle.name, - vehicleType: vehicle.type, - }); - - // Re-render - renderVehicles(); - updateStats(); - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - showVehicleDetails(vehicle); - } -} - -// Store vehicle -function storeVehicle(vehicle) { - console.log("Storing vehicle:", vehicle.name); - - // Update local state - vehicle.status = "stored"; - vehicle.location = "Garage A"; - - sendEvent("garage::store", { - vehicleId: vehicle.id, - vehicleName: vehicle.name, - vehicleType: vehicle.type, - }); - - // Re-render - renderVehicles(); - updateStats(); - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - showVehicleDetails(vehicle); - } -} - -// Update stats -function updateStats() { - const stored = mockData.vehicles.filter( - (v) => v.status === "stored", - ).length; - const active = mockData.vehicles.filter( - (v) => v.status === "active", - ).length; - const capacity = mockData.vehicles.length + 6; // Mock capacity - - document.getElementById("storedCount").textContent = stored; - document.getElementById("activeCount").textContent = active; - document.getElementById("capacityCount").textContent = capacity; -} - -// Update garage data from external source -function updateGarageData(data) { - if (data.vehicles) { - mockData.vehicles = data.vehicles; - renderVehicles(); - updateStats(); - - // Update selected vehicle if it still exists - if (selectedVehicle) { - const updated = mockData.vehicles.find( - (v) => v.id === selectedVehicle.id, - ); - if (updated) { - selectedVehicle = updated; - showVehicleDetails(updated); - } else { - selectedVehicle = null; - const noSelection = document.getElementById("noSelection"); - const vehicleDetails = - document.getElementById("vehicleDetails"); - if (noSelection) noSelection.style.display = "flex"; - if (vehicleDetails) vehicleDetails.style.display = "none"; - } - } - } -} - -// Send event to Arma -function sendEvent(event, data) { - if (typeof A3API !== "undefined") { - A3API.SendAlert( - JSON.stringify({ - event: event, - data: data, - }), - ); - } else { - console.log("Event:", event, "Data:", data); - } -} - -// Auto-initialize -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initGarage); -} else { - initGarage(); -} - -// Expose functions globally -window.updateGarageData = updateGarageData; -window.selectVehicle = selectVehicle; -window.spawnVehicle = spawnVehicle; -window.storeVehicle = storeVehicle; diff --git a/arma/client/addons/garage/ui/_site/style.css b/arma/client/addons/garage/ui/_site/style.css deleted file mode 100644 index a5c1ef7..0000000 --- a/arma/client/addons/garage/ui/_site/style.css +++ /dev/null @@ -1,605 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.7); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.garage-container { - height: 100vh; - width: 100vw; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -/* Header Section */ -.garage-header { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.25rem 1.5rem; - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); -} - -.garage-logo { - width: 60px; - height: 60px; - background: rgba(20, 30, 45, 0.8); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.logo-icon { - font-size: 2rem; -} - -.garage-info { - flex: 1; -} - -.garage-title { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.garage-subtitle { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.8); - letter-spacing: 0.5px; -} - -.garage-stats { - display: flex; - gap: 1.5rem; -} - -.stat-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.75rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.stat-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.8); -} - -.stat-value { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -.action-btn { - padding: 0.625rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.action-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.close-btn { - border-color: rgba(200, 100, 100, 0.4); -} - -.close-btn:hover { - border-color: rgba(255, 100, 100, 0.7); - box-shadow: - 0 0 15px rgba(200, 100, 100, 0.2), - inset 0 0 20px rgba(200, 100, 100, 0.05); -} - -/* Main Content */ -.garage-content { - flex: 1; - display: grid; - grid-template-columns: 250px 1fr 350px; - gap: 1.5rem; - overflow: hidden; -} - -/* Panels */ -.garage-panel { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.1), - 0 4px 16px rgba(0, 0, 0, 0.6); -} - -.panel-header { - padding: 1.25rem 1.5rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); -} - -.panel-title { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); -} - -.panel-content { - flex: 1; - padding: 1.5rem; - overflow-y: auto; -} - -/* Custom Scrollbar */ -.panel-content::-webkit-scrollbar { - width: 8px; -} - -.panel-content::-webkit-scrollbar-track { - background: rgba(15, 20, 30, 0.5); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb:hover { - background: rgba(100, 150, 200, 0.5); -} - -/* Filters */ -.filter-section { - margin-bottom: 2rem; -} - -.filter-section:last-child { - margin-bottom: 0; -} - -.filter-title { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(160, 180, 200, 0.85); - margin-bottom: 0.75rem; -} - -.filter-buttons { - display: flex; - gap: 0.5rem; -} - -.filter-btn { - flex: 1; - padding: 0.625rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.filter-btn:hover { - background: rgba(30, 45, 70, 0.8); - border-color: rgba(150, 200, 255, 0.5); -} - -.filter-btn.active { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.6); - box-shadow: - 0 0 10px rgba(100, 150, 200, 0.15), - inset 0 0 15px rgba(100, 150, 200, 0.05); -} - -.type-list { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.type-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - text-align: left; -} - -.type-item:hover { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(150, 200, 255, 0.7); -} - -.type-item.active { - background: rgba(30, 45, 70, 0.9); - border-left-color: rgba(100, 200, 150, 0.8); - box-shadow: - 0 0 15px rgba(100, 200, 150, 0.15), - inset 0 0 20px rgba(100, 200, 150, 0.05); -} - -.type-icon { - font-size: 1.5rem; -} - -.type-name { - font-size: 0.875rem; - color: rgba(200, 220, 240, 0.95); -} - -.search-input { - width: 100%; - padding: 0.75rem 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.search-input:focus { - outline: none; - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.search-input::placeholder { - color: rgba(100, 120, 140, 0.6); -} - -/* Vehicles Grid */ -.vehicles-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; -} - -.vehicle-card { - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - padding: 1.25rem; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.vehicle-card:hover { - background: rgba(30, 45, 70, 0.7); - border-left-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.vehicle-card.selected { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(100, 200, 150, 0.8); - box-shadow: - 0 0 20px rgba(100, 200, 150, 0.2), - inset 0 0 25px rgba(100, 200, 150, 0.05); -} - -.vehicle-icon { - font-size: 3rem; - text-align: center; -} - -.vehicle-name { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - text-align: center; -} - -.vehicle-type { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.85); - text-align: center; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.vehicle-status { - padding: 0.375rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 3px; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - text-align: center; - font-weight: 600; -} - -.vehicle-status.stored { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.4); - color: rgba(150, 200, 255, 0.9); -} - -.vehicle-status.active { - background: rgba(100, 200, 150, 0.2); - border-color: rgba(100, 200, 150, 0.4); - color: rgba(150, 255, 200, 0.9); -} - -/* Vehicle Details */ -.no-selection { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 3rem 1rem; -} - -.no-selection-icon { - font-size: 4rem; - opacity: 0.3; -} - -.no-selection p { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.7); -} - -.vehicle-details { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.detail-header { - display: flex; - align-items: center; - gap: 1rem; - padding: 1.25rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; -} - -.detail-icon { - font-size: 3rem; -} - -.detail-info { - flex: 1; -} - -.detail-name { - font-size: 1.125rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.detail-type { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.85); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.detail-stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; -} - -.detail-stat { - padding: 0.875rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.detail-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -.detail-value { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 240, 0.95); -} - -.detail-actions { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.detail-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - cursor: pointer; - transition: all 0.15s ease; -} - -.detail-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.spawn-btn { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); -} - -.spawn-btn:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); -} - -.store-btn { - background: rgba(200, 150, 100, 0.2); - border-color: rgba(200, 150, 100, 0.4); -} - -.store-btn:hover { - background: rgba(200, 150, 100, 0.3); - border-color: rgba(255, 200, 150, 0.6); -} - -.btn-icon { - font-size: 1.25rem; -} - -.btn-text { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 240, 0.95); -} - -.detail-specs { - padding: 1.25rem; - background: rgba(20, 30, 45, 0.5); - border: 1px solid rgba(100, 150, 200, 0.2); - border-radius: 4px; -} - -.specs-title { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(180, 200, 220, 0.9); - margin-bottom: 1rem; -} - -.specs-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.spec-item { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.15); -} - -.spec-item:last-child { - padding-bottom: 0; - border-bottom: none; -} - -.spec-label { - font-size: 0.8rem; - color: rgba(140, 160, 180, 0.85); -} - -.spec-value { - font-size: 0.875rem; - font-weight: 600; - color: rgba(200, 220, 240, 0.95); -} - -/* Responsive */ -@media (max-width: 1400px) { - .garage-content { - grid-template-columns: 220px 1fr 320px; - } - - .vehicles-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - } -} - -@media (max-width: 1200px) { - .garage-content { - grid-template-columns: 1fr 350px; - } - - .filters-panel { - display: none; - } -} diff --git a/arma/client/addons/garage/ui/src/bootstrap.js b/arma/client/addons/garage/ui/src/bootstrap.js new file mode 100644 index 0000000..f6b5949 --- /dev/null +++ b/arma/client/addons/garage/ui/src/bootstrap.js @@ -0,0 +1,19 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const GarageApp = window.GarageApp; + const app = ForgeWebUI.createApp({ + name: "garage", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => GarageApp.components.App(), { + preserveScroll: true, + }); + + if (GarageApp.bridge) { + GarageApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/garage/ui/src/bridge.js b/arma/client/addons/garage/ui/src/bridge.js new file mode 100644 index 0000000..c86b282 --- /dev/null +++ b/arma/client/addons/garage/ui/src/bridge.js @@ -0,0 +1,87 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "garage::close", + globalName: "ForgeBridge", + readyEvent: "garage::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestRefresh() { + return bridge.send("garage::refresh", {}); + } + + function requestRetrieve(payload) { + return bridge.send("garage::vehicle::retrieve::request", payload); + } + + function requestStore(payload) { + return bridge.send("garage::vehicle::store::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + function hydrate(payloadData) { + GarageApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + } + + bridge.on("garage::hydrate", hydrate); + bridge.on("garage::sync", hydrate); + + bridge.on("garage::retrieve::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle retrieved from the garage.", + ); + } + }); + + bridge.on("garage::retrieve::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to retrieve vehicle.", + ); + } + }); + + bridge.on("garage::store::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle stored in the garage.", + ); + } + }); + + bridge.on("garage::store::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to store vehicle.", + ); + } + }); + + GarageApp.bridge = { + notifyReady, + receive: bridge.receive, + requestClose, + requestRefresh, + requestRetrieve, + requestStore, + sendEvent: bridge.send, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/components/AppShell.js b/arma/client/addons/garage/ui/src/components/AppShell.js new file mode 100644 index 0000000..4eeb81e --- /dev/null +++ b/arma/client/addons/garage/ui/src/components/AppShell.js @@ -0,0 +1,827 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { h } = GarageApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = GarageApp.store; + const actions = GarageApp.actions; + const { categories, garage, nearby, session } = GarageApp.data; + + function q(query, values) { + const needle = String(query || "") + .trim() + .toLowerCase(); + if (!needle) { + return true; + } + + return values.some((value) => + String(value || "") + .toLowerCase() + .includes(needle), + ); + } + + function pct(value) { + return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 100))); + } + + function categoryLabel(category) { + const match = categories.find( + (entry) => entry.id === String(category || "other").toLowerCase(), + ); + return match ? match.label : "Other"; + } + + function distanceLabel(value) { + return `${Math.round(Number(value || 0))} m`; + } + + function plateLabel(value) { + return String(value || "").trim() || "Untracked"; + } + + function statusLabel(vehicle) { + if (!vehicle) { + return "-"; + } + + if (vehicle.entryKind === "stored") { + return "Stored"; + } + + return vehicle.isEmpty === false ? "Crewed" : "Ready"; + } + + function normalizeHitPointLabel(value) { + return String(value || "") + .replace(/^Hit/i, "") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); + } + + function sameEntry(left, right) { + if (!left || !right) { + return false; + } + + return ( + String(left.entryKind || "") === String(right.entryKind || "") && + String(left.plate || "") === String(right.plate || "") && + String(left.netId || "") === String(right.netId || "") + ); + } + + function selectedEntry(state) { + if (state.selectedKind === "stored") { + return ( + (garage.vehicles || []).find( + (vehicle) => + String(vehicle.plate || "") === state.selectedId, + ) || null + ); + } + + if (state.selectedKind === "nearby") { + return ( + (nearby.vehicles || []).find( + (vehicle) => + String(vehicle.netId || "") === state.selectedId, + ) || null + ); + } + + return null; + } + + function visibleVehicles(vehicles, state) { + return (vehicles || []).filter((vehicle) => { + if ( + state.categoryFilter !== "all" && + String(vehicle.category || "").toLowerCase() !== + state.categoryFilter + ) { + return false; + } + + return q(state.searchQuery, [ + vehicle.displayName, + vehicle.classname, + vehicle.plate, + vehicle.netId, + vehicle.category, + ]); + }); + } + + function stat(label, value, tone = "") { + return h( + "div", + { + className: tone + ? `garage-stat-card is-${tone}` + : "garage-stat-card", + }, + h("span", { className: "garage-stat-label" }, label), + h("span", { className: "garage-stat-value" }, value), + ); + } + + function meter(label, percent, tone) { + return h( + "div", + { className: "garage-meter" }, + h( + "div", + { className: "garage-meter-label-row" }, + h("span", { className: "garage-meter-label" }, label), + h("span", { className: "garage-meter-value" }, `${percent}%`), + ), + h( + "div", + { className: "garage-meter-track" }, + h("span", { + className: `garage-meter-fill is-${tone}`, + style: { width: `${percent}%` }, + }), + ), + ); + } + + function vehicleItem(vehicle, currentSelection) { + const id = + vehicle.entryKind === "stored" + ? String(vehicle.plate || "") + : String(vehicle.netId || ""); + const isNearby = vehicle.entryKind === "nearby"; + + return h( + "button", + { + type: "button", + className: sameEntry(vehicle, currentSelection) + ? "garage-vehicle-item is-selected" + : "garage-vehicle-item", + onClick: () => actions.selectEntry(vehicle.entryKind, id), + }, + h( + "div", + { className: "garage-vehicle-item-head" }, + h( + "div", + { className: "garage-vehicle-copy" }, + h( + "span", + { className: "garage-vehicle-title" }, + vehicle.displayName || vehicle.classname || "Vehicle", + ), + h( + "span", + { className: "garage-vehicle-meta" }, + isNearby + ? `Nearby ${distanceLabel(vehicle.distance)}` + : `Plate ${plateLabel(vehicle.plate)}`, + ), + ), + h( + "span", + { + className: + isNearby && vehicle.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isNearby + ? vehicle.isEmpty === false + ? "Crewed" + : "Empty" + : categoryLabel(vehicle.category), + ), + ), + h( + "div", + { className: "garage-inline-meters" }, + meter("Health", pct(vehicle.health), "health"), + meter("Fuel", pct(vehicle.fuel), "fuel"), + ), + ); + } + + function vehicleList(title, eyebrow, scrollId, vehicles, currentSelection) { + return h( + "section", + { className: "garage-card garage-list-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, eyebrow), + h("h2", { className: "garage-section-title" }, title), + ), + h( + "span", + { className: "garage-pill" }, + `${vehicles.length} ${vehicles.length === 1 ? "Vehicle" : "Vehicles"}`, + ), + ), + h( + "div", + { + className: "garage-card-body garage-scroll-body", + "data-preserve-scroll-id": scrollId, + }, + vehicles.length > 0 + ? vehicles.map((vehicle) => + vehicleItem(vehicle, currentSelection), + ) + : h( + "div", + { className: "garage-empty-state" }, + h( + "h3", + { className: "garage-empty-title" }, + "No matching vehicles", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Adjust the current search or category filter to view more records.", + ), + ), + ), + ); + } + + function hitPointRows(hitPoints) { + const rows = (Array.isArray(hitPoints) ? hitPoints : []) + .slice() + .sort( + (left, right) => + Number(right.value || 0) - Number(left.value || 0), + ) + .slice(0, 6) + .filter((row) => Number(row.value || 0) > 0); + + if (rows.length === 0) { + return h( + "div", + { className: "garage-empty-inline" }, + "No subsystem damage reported.", + ); + } + + return h( + "div", + { className: "garage-hitpoint-grid" }, + rows.map((row) => + h( + "div", + { className: "garage-hitpoint-row" }, + h( + "div", + { className: "garage-hitpoint-copy" }, + h( + "span", + { className: "garage-hitpoint-name" }, + normalizeHitPointLabel(row.name) || "Subsystem", + ), + row.selection + ? h( + "span", + { className: "garage-hitpoint-selection" }, + row.selection, + ) + : null, + ), + h( + "span", + { className: "garage-hitpoint-value" }, + `${Math.round(Number(row.value || 0) * 100)}%`, + ), + ), + ), + ); + } + + function detailPanel(currentSelection, state) { + if (!currentSelection) { + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, "Selection"), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Detail", + ), + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-empty" }, + h( + "h3", + { className: "garage-empty-title" }, + "Select a vehicle", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Choose a stored record to retrieve or a nearby vehicle to store.", + ), + ), + ); + } + + const isStored = currentSelection.entryKind === "stored"; + const pendingAction = String(state.pendingAction || ""); + const isBusy = + pendingAction === "retrieve" || pendingAction === "store"; + const canRetrieve = isStored && !session.spawnBlocked && !isBusy; + const canStore = + !isStored && currentSelection.isEmpty !== false && !isBusy; + + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + isStored ? "Stored Record" : "Nearby Vehicle", + ), + h( + "h2", + { className: "garage-section-title" }, + currentSelection.displayName || + currentSelection.classname || + "Vehicle", + ), + ), + h( + "span", + { + className: + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isStored + ? `Plate ${plateLabel(currentSelection.plate)}` + : currentSelection.isEmpty === false + ? "Crewed" + : "Ready", + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-body" }, + h( + "div", + { className: "garage-detail-grid" }, + h( + "div", + { className: "garage-detail-copy" }, + h( + "div", + { className: "garage-detail-meta" }, + stat( + "Category", + categoryLabel(currentSelection.category), + ), + stat( + "Status", + statusLabel(currentSelection), + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "danger" + : "", + ), + stat( + isStored ? "Record" : "Distance", + isStored + ? plateLabel(currentSelection.plate) + : distanceLabel(currentSelection.distance), + isStored ? "" : "accent", + ), + ), + h( + "div", + { className: "garage-meter-stack" }, + meter( + "Health", + pct(currentSelection.health), + "health", + ), + meter("Fuel", pct(currentSelection.fuel), "fuel"), + ), + h( + "div", + { className: "garage-action-row" }, + isStored + ? h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canRetrieve, + onClick: () => + actions.requestRetrieveSelected(), + }, + pendingAction === "retrieve" + ? "Retrieving..." + : "Retrieve Vehicle", + ) + : h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canStore, + onClick: () => + actions.requestStoreSelected(), + }, + pendingAction === "store" + ? "Storing..." + : "Store Vehicle", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: isBusy, + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "p", + { className: "garage-detail-note" }, + isStored + ? session.spawnBlocked + ? "The garage spawn lane is currently blocked." + : "Retrieve this stored vehicle into the active spawn lane." + : currentSelection.isEmpty === false + ? "Only empty nearby vehicles can be stored." + : "Store this nearby vehicle back into persistent garage storage.", + ), + ), + h( + "div", + { className: "garage-detail-subsystems" }, + h( + "div", + { className: "garage-subsystem-header" }, + h( + "span", + { className: "garage-eyebrow" }, + "Subsystems", + ), + h( + "span", + { className: "garage-detail-caption" }, + "Highest damage first", + ), + ), + hitPointRows(currentSelection.hitPoints), + ), + ), + ), + ); + } + + GarageApp.components = GarageApp.components || {}; + GarageApp.components.App = function App() { + const state = { + categoryFilter: store.getCategoryFilter(), + notice: store.getNotice(), + pendingAction: store.getPendingAction(), + searchQuery: store.getSearchQuery(), + selectedId: store.getSelectedId(), + selectedKind: store.getSelectedKind(), + }; + const currentSelection = selectedEntry(state); + const storedVehicles = visibleVehicles(garage.vehicles || [], state); + const nearbyVehicles = visibleVehicles(nearby.vehicles || [], state); + const searchLabel = state.searchQuery + ? `Search: ${state.searchQuery}` + : "Live"; + + return h( + "div", + { className: "garage-shell" }, + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Vehicle Garage", + onClose: () => actions.closeGarage(), + closeLabel: "Close garage interface", + }), + state.notice.text + ? h( + "div", + { className: "garage-toast-stack" }, + h( + "div", + { + className: + state.notice.type === "error" + ? "garage-toast is-error" + : "garage-toast is-success", + }, + state.notice.text, + ), + ) + : null, + h( + "div", + { className: "garage-layout" }, + h( + "aside", + { className: "garage-sidebar" }, + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Search", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Records", + ), + ), + h( + "span", + { className: "garage-pill" }, + searchLabel, + ), + ), + h( + "div", + { className: "garage-search-form" }, + h("input", { + id: "garage-search-input", + type: "text", + className: "garage-search-input", + placeholder: + "Search by name, plate, or category", + value: state.searchQuery, + }), + h( + "div", + { className: "garage-search-actions" }, + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + onClick: () => + actions.applySearchQuery( + document.getElementById( + "garage-search-input", + )?.value || "", + ), + }, + "Apply Search", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + onClick: () => actions.clearSearch(), + }, + "Clear", + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Filter", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Categories", + ), + ), + ), + h( + "div", + { className: "garage-category-grid" }, + categories.map((category) => + h( + "button", + { + type: "button", + className: + state.categoryFilter === category.id + ? "garage-chip is-active" + : "garage-chip", + onClick: () => + actions.selectCategory(category.id), + }, + category.label, + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Status", + ), + h( + "h2", + { className: "garage-section-title" }, + "Garage Summary", + ), + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: Boolean(state.pendingAction), + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "div", + { className: "garage-summary-grid" }, + stat( + "Stored", + `${session.capacityUsed}/${session.capacityMax}`, + ), + stat("Nearby", session.nearbyCount, "accent"), + stat( + "Spawn Lane", + session.spawnStatus, + session.spawnBlocked ? "danger" : "", + ), + ), + ), + ), + h( + "main", + { className: "garage-main" }, + h( + "section", + { className: "garage-panel" }, + h( + "div", + { className: "garage-panel-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Operations Bay", + ), + h( + "h1", + { className: "garage-title" }, + session.garageName || "Vehicle Garage", + ), + ), + h( + "span", + { className: "garage-pill" }, + `${session.capacityUsed}/${session.capacityMax} Stored`, + ), + ), + h( + "div", + { className: "garage-panel-intro" }, + h( + "p", + { className: "garage-copy" }, + "Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.", + ), + ), + h( + "div", + { className: "garage-dashboard" }, + vehicleList( + "Stored Vehicles", + "Persistent Records", + "garage-stored-list", + storedVehicles, + currentSelection, + ), + vehicleList( + "Nearby Vehicles", + "Store Window", + "garage-nearby-list", + nearbyVehicles, + currentSelection, + ), + detailPanel(currentSelection, state), + ), + ), + ), + ), + h( + "footer", + { className: "garage-footer" }, + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Storage Capacity", + ), + h( + "span", + { className: "garage-footer-copy" }, + `${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`, + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Retrieval Window", + ), + h( + "span", + { className: "garage-footer-copy" }, + session.spawnBlocked + ? "Spawn lane is blocked. Clear the bay before retrieving another vehicle." + : "Spawn lane is clear. Stored vehicles can be retrieved immediately.", + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Store Rules", + ), + h( + "span", + { className: "garage-footer-copy" }, + "Only nearby empty vehicles can be stored. Nearby count updates from the live world state.", + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/garage/ui/src/data.js b/arma/client/addons/garage/ui/src/data.js new file mode 100644 index 0000000..deca479 --- /dev/null +++ b/arma/client/addons/garage/ui/src/data.js @@ -0,0 +1,57 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + const defaultSession = { + garageName: "Vehicle Garage", + capacityUsed: 0, + capacityMax: 5, + nearbyCount: 0, + spawnBlocked: false, + spawnStatus: "Ready", + }; + + const defaultGarage = { + vehicles: [], + }; + + const defaultNearby = { + vehicles: [], + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + GarageApp.data = { + categories: [ + { id: "all", label: "All" }, + { id: "car", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "air", label: "Air" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + session: Object.assign({}, defaultSession), + garage: Object.assign({}, defaultGarage), + nearby: Object.assign({}, defaultNearby), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.garage, + Object.assign({}, defaultGarage, payload?.garage || {}), + ); + replaceObject( + this.nearby, + Object.assign({}, defaultNearby, payload?.nearby || {}), + ); + }, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/registry/events.js b/arma/client/addons/garage/ui/src/registry/events.js new file mode 100644 index 0000000..3ca41d3 --- /dev/null +++ b/arma/client/addons/garage/ui/src/registry/events.js @@ -0,0 +1,174 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + + let noticeTimer = null; + + function getStoredVehicles() { + return Array.isArray(GarageApp.data?.garage?.vehicles) + ? GarageApp.data.garage.vehicles + : []; + } + + function getNearbyVehicles() { + return Array.isArray(GarageApp.data?.nearby?.vehicles) + ? GarageApp.data.nearby.vehicles + : []; + } + + function getSelectedEntry() { + const selection = store.getSelection(); + if (selection.kind === "stored") { + return ( + getStoredVehicles().find( + (vehicle) => String(vehicle.plate || "") === selection.id, + ) || null + ); + } + + if (selection.kind === "nearby") { + return ( + getNearbyVehicles().find( + (vehicle) => String(vehicle.netId || "") === selection.id, + ) || null + ); + } + + return null; + } + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ type: "", text: "" }); + noticeTimer = null; + }, 3200); + } + + function closeGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage bridge is unavailable."); + return false; + } + + function refreshGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestRefresh === "function") { + const sent = bridge.requestRefresh(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage refresh bridge is unavailable."); + return false; + } + + function applySearchQuery(value) { + store.setSearchQuery(String(value || "").trim()); + } + + function clearSearch() { + store.setSearchQuery(""); + } + + function selectCategory(categoryId) { + store.setCategoryFilter(String(categoryId || "all").trim() || "all"); + } + + function selectEntry(kind, id) { + store.select(kind, id); + } + + function requestRetrieveSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "stored") { + showNotice("error", "Select a stored vehicle to retrieve."); + return false; + } + + if (GarageApp.data?.session?.spawnBlocked) { + showNotice("error", "The garage spawn area is blocked."); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestRetrieve !== "function") { + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + store.startAction("retrieve"); + const sent = bridge.requestRetrieve({ + plate: selectedEntry.plate || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + return true; + } + + function requestStoreSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "nearby") { + showNotice("error", "Select a nearby vehicle to store."); + return false; + } + + if (selectedEntry.isEmpty === false) { + showNotice( + "error", + "All crew must exit the vehicle before storing it.", + ); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestStore !== "function") { + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + store.startAction("store"); + const sent = bridge.requestStore({ + netId: selectedEntry.netId || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + return true; + } + + GarageApp.actions = { + showNotice, + closeGarage, + refreshGarage, + applySearchQuery, + clearSearch, + selectCategory, + selectEntry, + getSelectedEntry, + requestRetrieveSelected, + requestStoreSelected, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/registry/store.js b/arma/client/addons/garage/ui/src/registry/store.js new file mode 100644 index 0000000..776c5bd --- /dev/null +++ b/arma/client/addons/garage/ui/src/registry/store.js @@ -0,0 +1,113 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { createSignal } = GarageApp.runtime; + + class GarageStore { + constructor() { + [this.getSelectedKind, this.setSelectedKind] = createSignal(""); + [this.getSelectedId, this.setSelectedId] = createSignal(""); + [this.getSearchQuery, this.setSearchQuery] = createSignal(""); + [this.getCategoryFilter, this.setCategoryFilter] = + createSignal("all"); + [this.getPendingAction, this.setPendingAction] = createSignal(""); + [this.getNotice, this.setNotice] = createSignal({ + type: "", + text: "", + }); + } + + getSelection() { + return { + id: this.getSelectedId(), + kind: this.getSelectedKind(), + }; + } + + clearSelection() { + this.setSelectedKind(""); + this.setSelectedId(""); + } + + select(kind, id) { + this.setSelectedKind(String(kind || "")); + this.setSelectedId(String(id || "")); + } + + startAction(action) { + this.setPendingAction(String(action || "")); + } + + finishAction() { + this.setPendingAction(""); + } + + matchesSelection(entry) { + if (!entry || typeof entry !== "object") { + return false; + } + + const selection = this.getSelection(); + if (!selection.kind || !selection.id) { + return false; + } + + if (selection.kind === "stored") { + return ( + entry.entryKind === "stored" && + String(entry.plate || "") === selection.id + ); + } + + if (selection.kind === "nearby") { + return ( + entry.entryKind === "nearby" && + String(entry.netId || "") === selection.id + ); + } + + return false; + } + + ensureSelection() { + const garageVehicles = Array.isArray( + GarageApp.data?.garage?.vehicles, + ) + ? GarageApp.data.garage.vehicles + : []; + const nearbyVehicles = Array.isArray( + GarageApp.data?.nearby?.vehicles, + ) + ? GarageApp.data.nearby.vehicles + : []; + const hasCurrentSelection = [ + ...garageVehicles, + ...nearbyVehicles, + ].some((entry) => this.matchesSelection(entry)); + + if (hasCurrentSelection) { + return; + } + + const firstStored = garageVehicles[0] || null; + if (firstStored) { + this.select("stored", firstStored.plate || ""); + return; + } + + const firstNearby = nearbyVehicles[0] || null; + if (firstNearby) { + this.select("nearby", firstNearby.netId || ""); + return; + } + + this.clearSelection(); + } + + hydrateFromPayload() { + this.finishAction(); + this.ensureSelection(); + } + } + + GarageApp.store = new GarageStore(); +})(); diff --git a/arma/client/addons/garage/ui/src/runtime.js b/arma/client/addons/garage/ui/src/runtime.js new file mode 100644 index 0000000..f6daa1d --- /dev/null +++ b/arma/client/addons/garage/ui/src/runtime.js @@ -0,0 +1,7 @@ +(function () { + const runtime = window.ForgeWebUI; + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + GarageApp.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/garage/ui/src/styles.css b/arma/client/addons/garage/ui/src/styles.css new file mode 100644 index 0000000..b41390f --- /dev/null +++ b/arma/client/addons/garage/ui/src/styles.css @@ -0,0 +1,573 @@ +:root { + --garage-shell-bg: #e4e3df; + --garage-surface: #f5f3ef; + --garage-surface-alt: #ece8e2; + --garage-border: rgba(74, 91, 110, 0.2); + --garage-border-strong: rgba(20, 46, 79, 0.18); + --garage-text-main: #1f2d3d; + --garage-text-muted: #6a7787; + --garage-text-subtle: #8792a0; + --garage-accent: #12365d; + --garage-accent-soft: #dbe7f3; + --garage-accent-line: rgba(18, 54, 93, 0.12); + --garage-warning: #8f5f26; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--garage-text-main); + background: var(--garage-shell-bg); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.72; +} + +:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.35); + outline-offset: 2px; +} + +#app { + width: 100%; + height: 100%; +} + +.garage-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--garage-shell-bg); +} + +.garage-layout { + flex: 1; + min-height: 0; + width: min(100%, 1613px); + margin: 0 auto; + padding: 1.25rem; + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + gap: 1.25rem; +} + +.garage-sidebar, +.garage-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.garage-main { + overflow: hidden; +} + +.garage-module, +.garage-panel, +.garage-card { + background: linear-gradient( + 180deg, + var(--garage-surface) 0%, + var(--garage-surface-alt) 100% + ); + border: 1px solid var(--garage-border); + border-radius: 1.35rem; +} + +.garage-module, +.garage-card { + padding: 1rem; +} + +.garage-module { + display: grid; + gap: 0.85rem; + align-content: start; +} + +.garage-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.garage-panel-header, +.garage-module-header, +.garage-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.garage-panel-header { + padding: 1rem 1rem 0; +} + +.garage-module-header { + align-items: flex-start; +} + +.garage-panel-intro { + padding: 0 1rem 1rem; + border-bottom: 1px solid var(--garage-accent-line); +} + +.garage-dashboard { + flex: 1; + min-height: 0; + padding: 1rem; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + align-items: stretch; +} + +.garage-list-card, +.garage-detail-card { + min-height: 0; + display: flex; + flex-direction: column; +} + +.garage-detail-card { + grid-column: 1 / -1; +} + +.garage-scroll-body { + flex: 1; + min-height: 20rem; + max-height: 24rem; + overflow: auto; + display: grid; + gap: 0.8rem; + padding-right: 0.2rem; +} + +.garage-detail-body { + padding-top: 0.95rem; +} + +.garage-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr); + gap: 1rem; +} + +.garage-detail-meta, +.garage-summary-grid, +.garage-search-actions, +.garage-category-grid, +.garage-action-row, +.garage-inline-meters, +.garage-hitpoint-grid, +.garage-footer { + display: grid; + gap: 0.75rem; +} + +.garage-detail-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +.garage-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.garage-summary-grid > :last-child { + grid-column: 1 / -1; +} + +.garage-search-actions, +.garage-action-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-category-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-footer { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 0.95rem 1.25rem 1.15rem; + border-top: 1px solid rgb(18 54 93 / 0.1); +} + +.garage-meter-stack { + display: grid; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.garage-eyebrow, +.garage-footer-title, +.garage-stat-label, +.garage-meter-label, +.garage-hitpoint-selection { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--garage-text-subtle); +} + +.garage-title, +.garage-section-title { + margin: 0.16rem 0 0; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--garage-text-main); +} + +.garage-title { + font-size: 1.1rem; +} + +.garage-section-title { + font-size: 1.05rem; +} + +.garage-copy, +.garage-detail-note, +.garage-empty-copy, +.garage-footer-copy, +.garage-vehicle-meta, +.garage-detail-caption { + margin: 0; + font-size: 0.92rem; + line-height: 1.48; + color: var(--garage-text-muted); +} + +.garage-pill, +.garage-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.48rem 0.8rem; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + background: var(--garage-accent-soft); + color: var(--garage-accent); +} + +.garage-badge.is-warning { + background: rgb(246 226 193 / 0.88); + color: var(--garage-warning); +} + +.garage-search-form { + display: grid; + gap: 0.75rem; +} + +.garage-search-input { + width: 100%; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.75); + color: var(--garage-text-main); +} + +.garage-stat-card { + min-width: 0; + padding: 0.85rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.garage-stat-card.is-accent { + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.92) 0%, + rgb(223 232 242 / 0.72) 100% + ); +} + +.garage-stat-card.is-danger { + background: linear-gradient( + 180deg, + rgb(254 242 242 / 0.95) 0%, + rgb(252 225 225 / 0.82) 100% + ); + border-color: rgb(220 151 151 / 0.38); +} + +.garage-stat-value { + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); + line-height: 1.3; + overflow-wrap: anywhere; + word-break: break-word; +} + +.garage-chip { + min-height: 2.6rem; + padding: 0.68rem 0.9rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); + color: var(--garage-text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-chip.is-active { + background: var(--garage-accent-soft); + color: var(--garage-accent); + border-color: rgb(18 54 93 / 0.2); +} + +.garage-vehicle-item { + width: 100%; + padding: 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + color: inherit; + text-align: left; +} + +.garage-vehicle-item.is-selected { + border-color: rgb(18 54 93 / 0.24); + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.96) 0%, + rgb(223 232 242 / 0.74) 100% + ); + box-shadow: 0 16px 26px rgb(18 54 93 / 0.08); +} + +.garage-vehicle-item-head, +.garage-meter-label-row, +.garage-subsystem-header, +.garage-hitpoint-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.garage-vehicle-copy, +.garage-hitpoint-copy, +.garage-footer-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.garage-vehicle-title, +.garage-hitpoint-name, +.garage-hitpoint-value { + font-size: 0.9rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter { + display: grid; + gap: 0.32rem; +} + +.garage-meter-track { + width: 100%; + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: rgb(18 54 93 / 0.08); +} + +.garage-meter-value { + font-size: 0.78rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter-fill { + display: block; + height: 100%; + border-radius: inherit; +} + +.garage-meter-fill.is-health { + background: linear-gradient(90deg, #2f7d5b 0%, #4eaa82 100%); +} + +.garage-meter-fill.is-fuel { + background: linear-gradient(90deg, #12365d 0%, #3c6792 100%); +} + +.garage-btn { + min-height: 2.75rem; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border-strong); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-btn-primary { + background: rgb(255 255 255 / 0.68); + color: var(--garage-accent); +} + +.garage-btn-primary:hover { + background: rgb(219 231 243 / 0.88); +} + +.garage-btn-secondary { + background: rgb(255 255 255 / 0.42); + color: var(--garage-text-muted); +} + +.garage-btn-secondary:hover { + background: rgb(255 255 255 / 0.6); + color: var(--garage-text-main); +} + +.garage-hitpoint-row { + padding: 0.72rem 0.78rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); +} + +.garage-detail-empty, +.garage-empty-state { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 100%; +} + +.garage-empty-title { + margin: 0 0 0.35rem; + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-empty-inline { + padding: 0.9rem; + border-radius: 0.85rem; + border: 1px dashed var(--garage-border); + color: var(--garage-text-muted); + background: rgb(255 255 255 / 0.36); +} + +.garage-toast-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.garage-toast { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--garage-border); + background: #fff; + box-shadow: 0 14px 28px rgb(16 34 56 / 0.14); + font-size: 0.92rem; +} + +.garage-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +.garage-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +@media (max-width: 1440px) { + .garage-layout { + grid-template-columns: 288px minmax(0, 1fr); + } + + .garage-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1120px) { + .garage-layout { + grid-template-columns: 1fr; + overflow: auto; + } + + .garage-main, + .garage-sidebar { + min-height: auto; + } + + .garage-dashboard { + grid-template-columns: 1fr; + } + + .garage-detail-card { + grid-column: auto; + } + + .garage-scroll-body { + max-height: none; + min-height: 16rem; + } + + .garage-footer { + grid-template-columns: 1fr; + } +} diff --git a/arma/client/addons/garage/ui/ui.config.mjs b/arma/client/addons/garage/ui/ui.config.mjs new file mode 100644 index 0000000..bd3a713 --- /dev/null +++ b/arma/client/addons/garage/ui/ui.config.mjs @@ -0,0 +1,33 @@ +export default { + addonName: "garage", + title: "FORGE Vehicle Garage", + logLabel: "Garage UI", + outputDir: "_site", + jsBundles: [ + { + name: "Garage UI app", + output: "garage-ui.js", + sources: [ + "src/runtime.js", + "src/data.js", + "src/registry/store.js", + "src/bridge.js", + "src/registry/events.js", + "src/components/AppShell.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Garage UI styles", + output: "garage-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["garage-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["garage-ui.js"], + }, +}; diff --git a/arma/client/addons/org/XEH_PREP.hpp b/arma/client/addons/org/XEH_PREP.hpp index e5ecefa..7d71bae 100644 --- a/arma/client/addons/org/XEH_PREP.hpp +++ b/arma/client/addons/org/XEH_PREP.hpp @@ -1,4 +1,4 @@ PREP(handleUIEvents); -PREP(initOrgClass); -PREP(initOrgUIBridge); +PREP(initClass); +PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index 2e9b559..3f9e4d9 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -1,7 +1,7 @@ #include "script_component.hpp" -if (isNil QGVAR(OrgClass)) then { call FUNC(initOrgClass); }; -if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initOrgUIBridge); }; +if (isNil QGVAR(OrgClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(initOrg), { GVAR(OrgClass) call ["init", []]; diff --git a/arma/client/addons/org/functions/fnc_initOrgClass.sqf b/arma/client/addons/org/functions/fnc_initClass.sqf similarity index 98% rename from arma/client/addons/org/functions/fnc_initOrgClass.sqf rename to arma/client/addons/org/functions/fnc_initClass.sqf index b4ee8d5..dab354d 100644 --- a/arma/client/addons/org/functions/fnc_initOrgClass.sqf +++ b/arma/client/addons/org/functions/fnc_initClass.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initOrgClass.sqf + * File: fnc_initClass.sqf * Author: IDSolutions * Date: 2026-02-13 * Last Update: 2026-02-13 @@ -17,7 +17,7 @@ * Org class object [HASHMAP OBJECT] * * Examples: - * call forge_client_org_fnc_initOrgClass + * call forge_client_org_fnc_initClass */ #pragma hemtt ignore_variables ["_self"] diff --git a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf b/arma/client/addons/org/functions/fnc_initUIBridge.sqf similarity index 98% rename from arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf rename to arma/client/addons/org/functions/fnc_initUIBridge.sqf index e402612..cfc5875 100644 --- a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf +++ b/arma/client/addons/org/functions/fnc_initUIBridge.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initOrgUIBridge.sqf + * File: fnc_initUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 * Last Update: 2026-03-13 @@ -17,7 +17,7 @@ * Org UI bridge object [HASHMAP OBJECT] * * Examples: - * call forge_client_org_fnc_initOrgUIBridge + * call forge_client_org_fnc_initUIBridge */ #pragma hemtt ignore_variables ["_self"] diff --git a/arma/client/addons/store/XEH_PREP.hpp b/arma/client/addons/store/XEH_PREP.hpp index b8aa106..780173e 100644 --- a/arma/client/addons/store/XEH_PREP.hpp +++ b/arma/client/addons/store/XEH_PREP.hpp @@ -1,6 +1,6 @@ -PREP(buildStoreUIPayload); +PREP(buildUIPayload); PREP(handleUIEvents); -PREP(initStoreCatalogService); -PREP(initStoreClass); -PREP(initStoreUIBridge); +PREP(initCatalogService); +PREP(initClass); +PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/store/XEH_postInitClient.sqf b/arma/client/addons/store/XEH_postInitClient.sqf index a14751c..bcf83fa 100644 --- a/arma/client/addons/store/XEH_postInitClient.sqf +++ b/arma/client/addons/store/XEH_postInitClient.sqf @@ -1,8 +1,8 @@ #include "script_component.hpp" -if (isNil QGVAR(StoreCatalogService)) then { call FUNC(initStoreCatalogService); }; -if (isNil QGVAR(StoreClass)) then { call FUNC(initStoreClass); }; -if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initStoreUIBridge); }; +if (isNil QGVAR(StoreCatalogService)) then { call FUNC(initCatalogService); }; +if (isNil QGVAR(StoreClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseCheckout), { params [["_payload", createHashMap, [createHashMap]]]; diff --git a/arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf b/arma/client/addons/store/functions/fnc_buildUIPayload.sqf similarity index 99% rename from arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf rename to arma/client/addons/store/functions/fnc_buildUIPayload.sqf index 1f47613..3b748ce 100644 --- a/arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf +++ b/arma/client/addons/store/functions/fnc_buildUIPayload.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_buildStoreUIPayload.sqf + * File: fnc_buildUIPayload.sqf * Author: IDSolutions * Date: 2026-03-13 * Public: No diff --git a/arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf b/arma/client/addons/store/functions/fnc_initCatalogService.sqf similarity index 99% rename from arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf rename to arma/client/addons/store/functions/fnc_initCatalogService.sqf index 5674009..6f63302 100644 --- a/arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf +++ b/arma/client/addons/store/functions/fnc_initCatalogService.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initStoreCatalogService.sqf + * File: fnc_initCatalogService.sqf * Author: IDSolutions * Date: 2026-03-13 * Public: No diff --git a/arma/client/addons/store/functions/fnc_initStoreClass.sqf b/arma/client/addons/store/functions/fnc_initClass.sqf similarity index 92% rename from arma/client/addons/store/functions/fnc_initStoreClass.sqf rename to arma/client/addons/store/functions/fnc_initClass.sqf index 583e131..f88d2ff 100644 --- a/arma/client/addons/store/functions/fnc_initStoreClass.sqf +++ b/arma/client/addons/store/functions/fnc_initClass.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initStoreClass.sqf + * File: fnc_initClass.sqf * Author: IDSolutions * Date: 2026-01-28 * Last Update: 2026-03-12 @@ -17,7 +17,7 @@ * Store class object [HASHMAP OBJECT] * * Example: - * call forge_client_store_fnc_initStoreClass + * call forge_client_store_fnc_initClass */ #pragma hemtt ignore_variables ["_self"] diff --git a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf b/arma/client/addons/store/functions/fnc_initUIBridge.sqf similarity index 96% rename from arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf rename to arma/client/addons/store/functions/fnc_initUIBridge.sqf index b600593..f6cba7e 100644 --- a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initUIBridge.sqf @@ -1,7 +1,7 @@ #include "..\script_component.hpp" /* - * File: fnc_initStoreUIBridge.sqf + * File: fnc_initUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 * Last Update: 2026-03-12 @@ -48,7 +48,7 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]]]; - private _payload = call FUNC(buildStoreUIPayload); + private _payload = call FUNC(buildUIPayload); _self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]]; }], ["handleCategoryRequest", compileFinal { @@ -78,7 +78,7 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ]]]; }], ["refreshStoreConfig", compileFinal { - private _payload = call FUNC(buildStoreUIPayload); + private _payload = call FUNC(buildUIPayload); _self call ["sendBridgeEvent", ["store::config::hydrate", _payload]]; }], ["handleCheckoutRequest", compileFinal { diff --git a/arma/client/addons/store/ui/_site/store-ui.js b/arma/client/addons/store/ui/_site/store-ui.js index 7fba4b4..d123d15 100644 --- a/arma/client/addons/store/ui/_site/store-ui.js +++ b/arma/client/addons/store/ui/_site/store-ui.js @@ -2778,6 +2778,12 @@ ${scopeSelector} .cart-line { gap: 0.75rem; } +${scopeSelector} .cart-line-copy { + min-width: 0; + display: grid; + gap: 0.18rem; +} + ${scopeSelector} .cart-line-top, ${scopeSelector} .cart-line-controls, ${scopeSelector} .summary-row { @@ -2790,13 +2796,9 @@ ${scopeSelector} .summary-row { ${scopeSelector} .cart-line-title { font-size: 0.92rem; font-weight: 700; -} - -${scopeSelector} .cart-line-code { - font-size: 0.72rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--store-text-subtle); + line-height: 1.32; + overflow-wrap: anywhere; + word-break: break-word; } ${scopeSelector} .qty-controls { @@ -3112,15 +3114,9 @@ ${scopeSelector} .cart-empty { { className: "cart-line-top" }, h( "div", - null, - h( - "div", - { - className: - "cart-line-code", - }, - item.code, - ), + { + className: "cart-line-copy", + }, h( "div", { diff --git a/arma/client/addons/store/ui/src/components/cart.js b/arma/client/addons/store/ui/src/components/cart.js index 9678424..4249d59 100644 --- a/arma/client/addons/store/ui/src/components/cart.js +++ b/arma/client/addons/store/ui/src/components/cart.js @@ -150,6 +150,12 @@ ${scopeSelector} .cart-line { gap: 0.75rem; } +${scopeSelector} .cart-line-copy { + min-width: 0; + display: grid; + gap: 0.18rem; +} + ${scopeSelector} .cart-line-top, ${scopeSelector} .cart-line-controls, ${scopeSelector} .summary-row { @@ -162,13 +168,9 @@ ${scopeSelector} .summary-row { ${scopeSelector} .cart-line-title { font-size: 0.92rem; font-weight: 700; -} - -${scopeSelector} .cart-line-code { - font-size: 0.72rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--store-text-subtle); + line-height: 1.32; + overflow-wrap: anywhere; + word-break: break-word; } ${scopeSelector} .qty-controls { @@ -484,15 +486,9 @@ ${scopeSelector} .cart-empty { { className: "cart-line-top" }, h( "div", - null, - h( - "div", - { - className: - "cart-line-code", - }, - item.code, - ), + { + className: "cart-line-copy", + }, h( "div", { diff --git a/arma/server/addons/garage/XEH_preInit.sqf b/arma/server/addons/garage/XEH_preInit.sqf index 6e5d97d..109ae47 100644 --- a/arma/server/addons/garage/XEH_preInit.sqf +++ b/arma/server/addons/garage/XEH_preInit.sqf @@ -65,6 +65,84 @@ PREP_RECOMPILE_END; GVAR(GarageStore) call ["remove", [GVAR(Registry), _uid]]; }] call CFUNC(addEventHandler); +[QGVAR(requestStoreVehicle), { + params [ + ["_uid", "", [""]], + ["_className", "", [""]], + ["_fuel", 0, [0]], + ["_damage", 0, [0]], + ["_hitPointsJson", "", [""]] + ]; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_uid isEqualTo "" || { _className isEqualTo "" } || { _hitPointsJson isEqualTo "" }) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", false], + ["message", "Missing vehicle data for garage storage."] + ]], _player] call CFUNC(targetEvent); + }; + + private _payloadJson = toJSON (createHashMapFromArray [ + ["classname", _className], + ["fuel", _fuel], + ["damage", _damage], + ["hit_points", fromJSON _hitPointsJson] + ]); + + ["garage:add", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", false], + ["message", format ["Failed to store vehicle: %1", _result]] + ]], _player] call CFUNC(targetEvent); + }; + + private _garage = fromJSON _result; + GVAR(Registry) set [_uid, _garage]; + + [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", true], + ["message", "Vehicle stored in garage."] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestRetrieveVehicle), { + params [["_uid", "", [""]], ["_plate", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_uid isEqualTo "" || { _plate isEqualTo "" }) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", false], + ["message", "Select a stored vehicle to retrieve."] + ]], _player] call CFUNC(targetEvent); + }; + + private _payloadJson = toJSON (createHashMapFromArray [["plate", _plate]]); + ["garage:remove", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", false], + ["message", format ["Failed to retrieve vehicle: %1", _result]] + ]], _player] call CFUNC(targetEvent); + }; + + private _garage = fromJSON _result; + GVAR(Registry) set [_uid, _garage]; + + [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", true], + ["message", "Vehicle retrieved from garage."] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + [QGVAR(requestInitVG), { params [["_uid", "", [""]]];