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
This commit is contained in:
Jacob Schmidt 2026-03-14 03:06:18 -05:00
parent e15d4b3066
commit bdc1e36e63
38 changed files with 4689 additions and 1387 deletions

View File

@ -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) };

View File

@ -1,3 +1,8 @@
PREP(initGarageClass);
PREP(handleUIEvents);
PREP(initCatalogService);
PREP(initClass);
PREP(initSessionService);
PREP(initUIBridge);
PREP(initVGClass);
PREP(openUI);
PREP(openVG);

View File

@ -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), {

View File

@ -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"

View File

@ -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;

View File

@ -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)

View File

@ -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]]];

View File

@ -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)

View File

@ -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)

View File

@ -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;

View File

@ -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";

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,261 +1,64 @@
<!-- Generated by tools/build-webui.mjs for garage UI index. Do not edit directly. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vehicle Garage</title>
<link rel="stylesheet" href="style.css" />
<!--
Dynamic Resource Loading
The following script loads CSS and JavaScript files dynamically using the A3API
This approach is used instead of static HTML imports to work with Arma 3's file system
-->
<title>FORGE Vehicle Garage</title>
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\garage\\ui\\_site\\style.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\garage\\ui\\_site\\script.js",
),
]).then(([css, js]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
window.ForgeSiteConfig = {
addonName: "garage",
logLabel: "Garage UI",
styles: ["garage-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["garage-ui.js"],
};
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
(function loadForgeSiteLoader() {
const armaLoaderPath =
"forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js";
const browserLoaderPath =
"../../../common/ui/_site/forge-site-loader.js";
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
function requestLoader() {
if (
typeof A3API !== "undefined" &&
A3API &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(armaLoaderPath);
}
return fetch(browserLoaderPath).then((response) => {
if (!response.ok) {
throw new Error(
"Failed to load " + browserLoaderPath,
);
}
return response.text();
});
}
requestLoader()
.then(appendScript)
.catch((error) => {
console.error(
"[Garage UI] Failed to load Forge site loader.",
error,
);
});
})();
</script>
</head>
<body>
<div class="garage-container">
<!-- Header Section -->
<div class="garage-header">
<div class="garage-logo">
<div class="logo-icon">🚗</div>
</div>
<div class="garage-info">
<h1 class="garage-title">Vehicle Garage</h1>
<p class="garage-subtitle">Vehicle Management System</p>
</div>
<div class="garage-stats">
<div class="stat-item">
<span class="stat-label">Stored</span>
<span class="stat-value" id="storedCount">12</span>
</div>
<div class="stat-item">
<span class="stat-label">Active</span>
<span class="stat-value" id="activeCount">2</span>
</div>
<div class="stat-item">
<span class="stat-label">Capacity</span>
<span class="stat-value" id="capacityCount">20</span>
</div>
</div>
<div class="header-actions">
<button class="action-btn close-btn">Close</button>
</div>
</div>
<!-- Main Content -->
<div class="garage-content">
<!-- Left Panel - Filters -->
<div class="garage-panel filters-panel">
<div class="panel-header">
<h2 class="panel-title">Filters</h2>
</div>
<div class="panel-content">
<!-- Status Filter -->
<div class="filter-section">
<h3 class="filter-title">Status</h3>
<div class="filter-buttons">
<button
class="filter-btn active"
data-filter="all"
>
All
</button>
<button class="filter-btn" data-filter="stored">
Stored
</button>
<button class="filter-btn" data-filter="active">
Active
</button>
</div>
</div>
<!-- Type Filter -->
<div class="filter-section">
<h3 class="filter-title">Vehicle Type</h3>
<div class="type-list">
<button
class="type-item active"
data-type="all"
>
<span class="type-icon">📦</span>
<span class="type-name">All Types</span>
</button>
<button class="type-item" data-type="car">
<span class="type-icon">🚗</span>
<span class="type-name">Cars</span>
</button>
<button class="type-item" data-type="truck">
<span class="type-icon">🚛</span>
<span class="type-name">Trucks</span>
</button>
<button class="type-item" data-type="air">
<span class="type-icon">🚁</span>
<span class="type-name">Aircraft</span>
</button>
<button class="type-item" data-type="sea">
<span class="type-icon">🚤</span>
<span class="type-name">Boats</span>
</button>
</div>
</div>
<!-- Search -->
<div class="filter-section">
<h3 class="filter-title">Search</h3>
<input
type="text"
class="search-input"
id="searchInput"
placeholder="Search vehicles..."
/>
</div>
</div>
</div>
<!-- Center Panel - Vehicle Grid -->
<div class="garage-panel vehicles-panel">
<div class="panel-header">
<h2 class="panel-title">Your Vehicles</h2>
</div>
<div class="panel-content">
<div class="vehicles-grid" id="vehiclesGrid">
<!-- Vehicles will be dynamically generated -->
</div>
</div>
</div>
<!-- Right Panel - Vehicle Details -->
<div class="garage-panel details-panel" id="detailsPanel">
<div class="panel-header">
<h2 class="panel-title">Vehicle Details</h2>
</div>
<div class="panel-content">
<div class="no-selection" id="noSelection">
<div class="no-selection-icon">🚗</div>
<p>Select a vehicle to view details</p>
</div>
<div
class="vehicle-details"
id="vehicleDetails"
style="display: none"
>
<div class="detail-header">
<div class="detail-icon" id="detailIcon">
🚗
</div>
<div class="detail-info">
<h3 class="detail-name" id="detailName">
Vehicle Name
</h3>
<p class="detail-type" id="detailType">
Type
</p>
</div>
</div>
<div class="detail-stats">
<div class="detail-stat">
<span class="detail-label">Status</span>
<span class="detail-value" id="detailStatus"
>Stored</span
>
</div>
<div class="detail-stat">
<span class="detail-label">Condition</span>
<span
class="detail-value"
id="detailCondition"
>100%</span
>
</div>
<div class="detail-stat">
<span class="detail-label">Fuel</span>
<span class="detail-value" id="detailFuel"
>100%</span
>
</div>
<div class="detail-stat">
<span class="detail-label">Location</span>
<span
class="detail-value"
id="detailLocation"
>Garage A</span
>
</div>
</div>
<div class="detail-actions">
<button
class="detail-btn spawn-btn"
id="spawnBtn"
>
<span class="btn-icon">🚀</span>
<span class="btn-text">Spawn Vehicle</span>
</button>
<button
class="detail-btn store-btn"
id="storeBtn"
style="display: none"
>
<span class="btn-icon">📦</span>
<span class="btn-text">Store Vehicle</span>
</button>
</div>
<div class="detail-specs">
<h4 class="specs-title">Specifications</h4>
<div class="specs-list">
<div class="spec-item">
<span class="spec-label">Seats</span>
<span
class="spec-value"
id="detailSeats"
>4</span
>
</div>
<div class="spec-item">
<span class="spec-label">Speed</span>
<span
class="spec-value"
id="detailSpeed"
>180 km/h</span
>
</div>
<div class="spec-item">
<span class="spec-label">Cargo</span>
<span
class="spec-value"
id="detailCargo"
>200 kg</span
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
<div id="app"></div>
</body>
</html>

View File

@ -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 = `
<div class="vehicle-icon">${vehicle.icon}</div>
<div class="vehicle-name">${vehicle.name}</div>
<div class="vehicle-type">${vehicle.type}</div>
<div class="vehicle-status ${vehicle.status}">${vehicle.status}</div>
`;
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;

View File

@ -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;
}
}

View File

@ -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();
})();

View File

@ -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,
};
})();

View File

@ -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.",
),
),
),
);
};
})();

View File

@ -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 || {}),
);
},
};
})();

View File

@ -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,
};
})();

View File

@ -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();
})();

View File

@ -0,0 +1,7 @@
(function () {
const runtime = window.ForgeWebUI;
const GarageApp = (window.GarageApp = window.GarageApp || {});
GarageApp.runtime = runtime;
window.AppRuntime = runtime;
})();

View File

@ -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;
}
}

View File

@ -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"],
},
};

View File

@ -1,4 +1,4 @@
PREP(handleUIEvents);
PREP(initOrgClass);
PREP(initOrgUIBridge);
PREP(initClass);
PREP(initUIBridge);
PREP(openUI);

View File

@ -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", []];

View File

@ -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"]

View File

@ -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"]

View File

@ -1,6 +1,6 @@
PREP(buildStoreUIPayload);
PREP(buildUIPayload);
PREP(handleUIEvents);
PREP(initStoreCatalogService);
PREP(initStoreClass);
PREP(initStoreUIBridge);
PREP(initCatalogService);
PREP(initClass);
PREP(initUIBridge);
PREP(openUI);

View File

@ -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]]];

View File

@ -1,7 +1,7 @@
#include "..\script_component.hpp"
/*
* File: fnc_buildStoreUIPayload.sqf
* File: fnc_buildUIPayload.sqf
* Author: IDSolutions
* Date: 2026-03-13
* Public: No

View File

@ -1,7 +1,7 @@
#include "..\script_component.hpp"
/*
* File: fnc_initStoreCatalogService.sqf
* File: fnc_initCatalogService.sqf
* Author: IDSolutions
* Date: 2026-03-13
* Public: No

View File

@ -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"]

View File

@ -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 {

View File

@ -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",
{

View File

@ -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",
{

View File

@ -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", "", [""]]];