Merge pull request 'Merge development into master: shared Web UI runtime, bridge-driven UIs, and server-authoritative store flow' (#1) from development into master

Reviewed-on: #1
This commit is contained in:
Jacob Schmidt 2026-03-14 20:12:08 -05:00
commit 7a8ca6b237
373 changed files with 28646 additions and 8914 deletions

View File

@ -1,12 +1,17 @@
# Contributing Setup & Guidelines
## Setting up the Development Environment
### 1. Clone the repository from GitHub
### 2. Install HEMTT
The latest version of HEMTT can be installed by running:
```cmd
winget install hemtt
```
## Coding Guidelines
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).

View File

@ -1,25 +1,31 @@
---
name: Bug report
about: Create a bug report to help us improve
title: ''
title: ""
labels: kind/bug
---
## Describe the bug
A clear and concise description of what the bug is.
## To reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behavior
A clear and concise description of what you expected to happen.
## Attachments
If applicable, add screenshots or RPT logs to help explain your problem.
## Additional context
Add any other context about the problem here.

View File

@ -1,15 +1,18 @@
---
name: Feature Request
about: Suggest a feature to be added
title: ''
title: ""
labels: kind/feature-request
---
## Describe the feature that you would like
A clear and concise description of the feature you'd want.
## Possible alternatives
Possible alternatives to your suggestion.
## Additional context
Add any other context about the feature here.

View File

@ -1,12 +1,16 @@
**When merged this pull request will:**
- Describe what this pull request will do
- Each change in a separate line
### Important
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
- [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied.
- [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`.
<!-- Known issues that need to be addressed -->
### Known Issues
- [ ] Issue

6
.gitignore vendored
View File

@ -21,6 +21,12 @@ target/
*.swo
*~
# Misc
node_modules/
# OS
.DS_Store
Thumbs.db
# Arma
arma/ui/map-viewer/

View File

@ -20,7 +20,7 @@ graph TD
end
subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
ActorRegistry["GVAR(ActorRegistry)<br/>In-Memory HashMap<br/>UID -> {loadout, position, stats...}"]
Registry["GVAR(Registry)<br/>In-Memory HashMap<br/>UID -> {loadout, position, stats...}"]
SessionMgmt[Session Management<br/>- Token Generation<br/>- UID Resolution<br/>- Player State]
end
@ -105,15 +105,18 @@ sequenceDiagram
## 🚀 **Performance Characteristics**
### **Access Times**
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
- **Client Cache**: `< 0.1ms` (Local object access)
### **Cache Hit Ratios**
- **Hot Cache**: `~95%` (Active players)
- **Cold Storage**: `~5%` (New connections, cache misses)
### **Memory Usage**
- **Server Registry**: `~1KB per active player`
- **Client Cache**: `~500B per player object`
- **Redis**: `~2KB per player (persistent)`
@ -125,7 +128,7 @@ flowchart TD
subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;]
Conn[Player Connection] --> Token[Session Token Generation<br/>#40;Generated on server#41;]
Token --> UID[UID Resolution<br/>#40;Steam UID mapping#41;]
UID --> State[Player State Tracking<br/>#40;Tracked in ActorRegistry#41;]
UID --> State[Player State Tracking<br/>#40;Tracked in Registry#41;]
State --> Access[Data Access Authorized<br/>#40;Authorized via session#41;]
end
```

View File

@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY
With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions:
* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
* **Noncommercial** - You may not use this material for any commercial purposes.
* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
- **Noncommercial** - You may not use this material for any commercial purposes.
- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
---
@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.

View File

@ -33,6 +33,7 @@ graph TD
```
**Communication Flow**:
- **Clients** → Use events (`CBA_Events`) to communicate with server
- **Server** → Calls Rust extension via `callExtension`
- **Extension** → Manages Redis connection pool and data operations
@ -87,12 +88,14 @@ forge/
1. Clone the repository from Gitea
2. Install HEMTT
The latest version of HEMTT can be installed by running:
The latest version of HEMTT can be installed by running:
```cmd
winget install hemtt
```
### Coding Guidelines
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
### Building the Extension
@ -143,14 +146,18 @@ private _update = createHashMapFromArray [["bank", 1500]];
## Core Modules
### Models
Defines strict data structures with built-in validation:
- `Actor`: Player data (stats, inventory, position)
- `Org`: Organization/clan data (members, roles, metadata)
[Documentation](lib/models/README.md)
### Repositories
Manages data persistence with Redis:
- Hash-based storage for structured data
- Set-based storage for collections
- Generic over Redis client implementations
@ -158,7 +165,9 @@ Manages data persistence with Redis:
[Documentation](lib/repositories/README.md)
### Services
Implements business logic and orchestration:
- Get-or-create patterns
- Data validation and transformation
- Complex workflows
@ -166,7 +175,9 @@ Implements business logic and orchestration:
[Documentation](lib/services/README.md)
### Extension
Arma 3 interface layer:
- Command routing and parsing
- Session management
- Error handling and logging
@ -174,7 +185,9 @@ Arma 3 interface layer:
[Documentation](arma/server/extension/README.md)
### Client Mod
Client-side SQF addon that provides:
- **UI Components**: Player interfaces for inventory, organizations, banking
- **Event Handlers**: CBA event listeners for server communication
- **Optimistic Caching**: Local data caching for instant UI updates
@ -182,6 +195,7 @@ Client-side SQF addon that provides:
- **Input Validation**: Client-side validation before server requests
The client mod communicates with the server using **CBA Events**, ensuring:
- No direct extension calls from clients (security)
- Event-driven architecture for scalability
- Automatic state synchronization across all clients
@ -190,28 +204,32 @@ The client mod communicates with the server using **CBA Events**, ensuring:
## Available Commands
### Actor Commands
| Command | Description |
|---------|-------------|
| `actor:get` | Retrieve actor data by UID |
| `actor:create` | Create a new actor |
| `actor:update` | Update actor fields |
| `actor:exists` | Check if actor exists |
| `actor:delete` | Delete actor data |
| Command | Description |
| -------------- | -------------------------- |
| `actor:get` | Retrieve actor data by UID |
| `actor:create` | Create a new actor |
| `actor:update` | Update actor fields |
| `actor:exists` | Check if actor exists |
| `actor:delete` | Delete actor data |
### Organization Commands
| Command | Description |
|---------|-------------|
| `org:get` | Retrieve organization data |
| `org:create` | Create a new organization |
| `org:update` | Update organization fields |
| `org:exists` | Check if organization exists |
| `org:delete` | Delete organization |
| `org:add_member` | Add member to organization |
| Command | Description |
| ------------------- | ------------------------------- |
| `org:get` | Retrieve organization data |
| `org:create` | Create a new organization |
| `org:update` | Update organization fields |
| `org:exists` | Check if organization exists |
| `org:delete` | Delete organization |
| `org:add_member` | Add member to organization |
| `org:remove_member` | Remove member from organization |
| `org:get_members` | Get all organization members |
| `org:get_members` | Get all organization members |
### Redis Operations
Direct Redis operations for advanced use cases:
- **Common**: Key-value operations (set, get, incr, decr, del)
- **Hash**: Structured data (hset, hget, hgetall, hdel)
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
@ -264,6 +282,7 @@ if (_response find "Error:" == 0) then {
## Logging
Logs are automatically created in `@forge_server/logs/`:
- `actor.log` - Actor operations
- `org.log` - Organization operations
- `redis.log` - Redis connection and operations

View File

@ -1,12 +1,17 @@
# Contributing Setup & Guidelines
## Setting up the Development Environment
### 1. Clone the repository from GitHub
### 2. Install HEMTT
The latest version of HEMTT can be installed by running:
```cmd
winget install hemtt
```
## Coding Guidelines
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).

View File

@ -1,25 +1,31 @@
---
name: Bug report
about: Create a bug report to help us improve
title: ''
title: ""
labels: kind/bug
---
## Describe the bug
A clear and concise description of what the bug is.
## To reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behavior
A clear and concise description of what you expected to happen.
## Attachments
If applicable, add screenshots or RPT logs to help explain your problem.
## Additional context
Add any other context about the problem here.

View File

@ -1,15 +1,18 @@
---
name: Feature Request
about: Suggest a feature to be added
title: ''
title: ""
labels: kind/feature-request
---
## Describe the feature that you would like
A clear and concise description of the feature you'd want.
## Possible alternatives
Possible alternatives to your suggestion.
## Additional context
Add any other context about the feature here.

View File

@ -1,12 +1,16 @@
**When merged this pull request will:**
- Describe what this pull request will do
- Each change in a separate line
### Important
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
- [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied.
- [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`.
<!-- Known issues that need to be addressed -->
### Known Issues
- [ ] Issue

View File

@ -12,17 +12,17 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout the source code
uses: actions/checkout@v4
- name: Checkout the source code
uses: actions/checkout@v4
- name: Validate Config
run: python tools/config_style_checker.py
- name: Check for BOM
uses: arma-actions/bom-check@master
with:
path: "addons"
- name: Validate Config
run: python tools/config_style_checker.py
- name: Check for BOM
uses: arma-actions/bom-check@master
with:
path: "addons"
- name: Setup HEMTT
uses: arma-actions/hemtt@v1
- name: Run HEMTT check
run: hemtt check --pedantic
- name: Setup HEMTT
uses: arma-actions/hemtt@v1
- name: Run HEMTT check
run: hemtt check --pedantic

View File

@ -3,6 +3,7 @@ hemtt.exe
.hemtt/missions/~*
.hemttout/
releases/
.hemttprivatekey
# Textures
Exports/

View File

@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY
With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions:
* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
* **Noncommercial** - You may not use this material for any commercial purposes.
* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
- **Noncommercial** - You may not use this material for any commercial purposes.
- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
---
@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.

View File

@ -18,10 +18,13 @@
The project is entirely **open-source** and any contributions are welcome.
## Core Features
- Feature
## Contributing
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
## License
Forge Client is licensed under [APL-SA](./LICENSE.md).

View File

@ -1,4 +1,3 @@
forge_client_actor
===================
# forge_client_actor
Description for this addon

View File

@ -23,7 +23,7 @@ player addEventHandler ["Respawn", {
[SRPC(economy,onRespawn), [_unit, _corpse, _uid]] call CFUNC(serverEvent);
}];
if (isNil QGVAR(ActorClass)) then { [] call FUNC(initActorClass); };
if (isNil QGVAR(ActorClass)) then { call FUNC(initActorClass); };
[QGVAR(initActor), {
GVAR(ActorClass) call ["init", []];

View File

@ -1,19 +1,25 @@
#include "..\script_component.hpp"
/*
* File: fnc_handleUIEvents.sqf
* Author: IDSolutions
* Date: 2026-01-28
* Last Update: 2026-02-17
* Public: No
*
* Description:
* Handles the UI events.
*
* Arguments:
* None
* 0: [CONTROL] - The control that triggered the event
* 1: [BOOL] - Whether the event is from a confirm dialog
* 2: [STRING] - The message containing the event data
*
* Return Value:
* None
* UI events handled [BOOL]
*
* Example:
* [] call forge_client_actor_fnc_handleUIEvents;
*
* Public: No
* call forge_client_actor_fnc_handleUIEvents;
*/
params ["_control", "_isConfirmDialog", "_message"];
@ -21,27 +27,25 @@ params ["_control", "_isConfirmDialog", "_message"];
private _alert = fromJSON _message;
private _event = _alert get "event";
private _data = _alert get "data";
private _display = displayChild findDisplay 46;
diag_log format ["[FORGE:Client:Actor] Handling UI event: %1 with data: %2", _event, _data];
switch (_event) do {
case "actor::get::actions": { GVAR(ActorClass) call ["getNearbyActions", [_control]]; };
case "actor::close::menu": { closeDialog 1; };
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."; }; // TODO: Implement device interaction
case "actor::open::garage": { hint "Garage interaction is not yet implemented."; }; // TODO: Implement garage interaction
case "actor::open::device": { hint "Device 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::locker": { hint "Locker interaction is not yet implemented."; }; // TODO: Implement locker interaction
case "actor::open::vlocker": { ["Open", [false, FORGE_Locker_Box, player]] spawn BFUNC(arsenal) };
// case "actor::open::phone": { [] spawn EFUNC(phone,openUI) };
case "actor::open::phone": { hint "Phone interaction is not yet implemented."; }; // TODO: Implement phone interaction
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." }; // TODO: Implement player interaction
case "actor::open::store": { hint "Store interaction is not yet implemented."; }; // TODO: Implement store interaction
case "actor::open::vlocker": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) };
case "actor::open::phone": { hint "Phone interaction is not yet implemented."; };
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." };
case "actor::open::store": { [] spawn EFUNC(store,openUI); };
default { hint format ["Unhandled UI event: %1", _event]; };
};
if (_event isNotEqualTo "actor::get::actions") then { _display closeDisplay 1; };
if (_event isNotEqualTo "actor::get::actions") then { closeDialog 1; };
true;

View File

@ -1,56 +1,43 @@
#include "..\script_component.hpp"
/*
* File: fnc_initActorClass.sqf
* Author: IDSolutions
* Initializes the actor class.
* Date: 2026-01-28
* Last Update: 2026-02-17
* Public: Yes
*
* Description:
* Initializes the actor class for managing player data.
* Provides methods for saving, loading, and applying actor data.
*
* Arguments:
* None
*
* Return Value:
* None
* Actor class object [HASHMAP OBJECT]
*
* Examples:
* [] call forge_client_actor_fnc_initActorClass
*
* Public: Yes
* Example:
* call forge_client_actor_fnc_initActorClass
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(ActorClass) = createHashMapObject [[
["#type", "IActorClass"],
["#create", {
GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [
["#type", "ActorBaseClass"],
["#create", compileFinal {
_self set ["uid", getPlayerUID player];
_self set ["actor", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
private _actor = createHashMap;
_actor set ["uid", (getPlayerUID player)];
_actor set ["name", (name player)];
_actor set ["loadout", [[],[],[],["U_BG_Guerrilla_6_1",[]],[],[],"H_Cap_blk_ION","",[],["ItemMap","ItemGPS","ItemRadio","ItemCompass","ItemWatch",""]]];
_actor set ["position", (getPosASL player)];
_actor set ["direction", (getDir player)];
_actor set ["stance", (stance player)];
_actor set ["rank", (rank player)];
_actor set ["state", (lifeState player)];
_actor set ["phone_number", ""];
_actor set ["email", ""];
_actor set ["organization", ""];
_actor set ["holster", true];
_self set ["actor", _actor];
}],
["init", {
["init", compileFinal {
private _uid = _self get "uid";
private _actor = _self get "actor";
[SRPC(actor,requestInitActor), [_uid, _actor]] call CFUNC(serverEvent);
[SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent);
systemChat format ["Actor loaded for %1", (name player)];
diag_log "[FORGE:Client:Actor] Actor Class Initialized!";
}],
["save", {
["save", compileFinal {
params [["_sync", false, [false]]];
private _uid = _self get "uid";
@ -58,16 +45,12 @@ GVAR(ActorClass) = createHashMapObject [[
_self set ["lastSave", time];
}],
["sync", {
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
private _actor = _self get "actor";
private _isLoaded = _self get "isLoaded";
if (_data isEqualTo createHashMap) exitWith {
diag_log "[FORGE:Client:Actor] Empty data received for sync, skipping.";
};
{
_actor set [_x, _y];
@ -89,13 +72,13 @@ GVAR(ActorClass) = createHashMapObject [[
if !(_isLoaded) then { _self set ["isLoaded", true]; };
diag_log "[FORGE:Client:Actor] Sync completed";
}],
["get", {
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _actor = _self get "actor";
_actor getOrDefault [_key, _default];
}],
["applyPosition", {
["applyPosition", compileFinal {
private _position = _self call ["get", ["position", [0, 0, 0]]];
if (GVAR(enableLoc)) then {
@ -112,33 +95,30 @@ GVAR(ActorClass) = createHashMapObject [[
};
};
}],
["applyDirection", {
["applyDirection", compileFinal {
private _direction = _self call ["get", ["direction", 0]];
if (GVAR(enableLoc)) then { player setDir _direction; };
}],
["applyStance", {
["applyStance", compileFinal {
private _stance = _self call ["get", ["stance", "STAND"]];
if (GVAR(enableLoc)) then { player playAction _stance; };
}],
["applyRank", {
["applyRank", compileFinal {
private _rank = _self call ["get", ["rank", "PRIVATE"]];
player setUnitRank _rank;
}],
["applyLoadout", {
["applyLoadout", compileFinal {
private _loadout = _self call ["get", ["loadout", []]];
if (GVAR(enableGear) && count _loadout > 0) then { player setUnitLoadout _loadout; };
}],
["getNearbyActions", {
["getNearbyActions", compileFinal {
params [["_control", controlNull, [controlNull]]];
private _nearbyActions = [];
{
private _storeType = _x getVariable ["storeType", ""];
private _isAtm = _x getVariable ["isAtm", false];
private _isBank = _x getVariable ["isBank", false];
private _isGarage = _x getVariable ["isGarage", false];
private _isLocker = _x getVariable ["isLocker", false];
@ -147,18 +127,18 @@ GVAR(ActorClass) = createHashMapObject [[
private _isPlayer = _x isKindOf "Man" && isPlayer _x;
if (_storeType isNotEqualTo "") then { _nearbyActions pushBack ["store", _storeType]; };
if (_isAtm) then { _nearbyActions pushBack ["atm", true]; };
if (_isBank) then { _nearbyActions pushBack ["bank", true]; };
if (_isLocker) then { _nearbyActions pushBack ["locker", true]; };
if (_isLocker && GVAR(enableVA)) then { _nearbyActions pushBack ["va", true]; };
if (_isGarage) then { _nearbyActions pushBack ["garage", _garageType]; };
if (_isGarage && GVAR(enableVG)) then { _nearbyActions pushBack ["vg", true]; };
if (_deviceType isNotEqualTo "") then { _nearbyActions pushBack ["device", _deviceType]; };
if (_isPlayer) then { _nearbyActions pushBack ["player", name _x]; };
if (_isPlayer && { _x isNotEqualTo player }) then { _nearbyActions pushBack ["player", name _x]; };
} forEach (player nearObjects 5);
_control ctrlWebBrowserAction ["ExecJS", format ["updateAvailableActions(%1)", (toJSON _nearbyActions)]];
}]
]];
];
SETVAR(player,FORGE_ActorClass,GVAR(ActorClass));
GVAR(ActorClass) = createHashMapObject [GVAR(ActorBaseClass)];
GVAR(ActorClass)

View File

@ -1,23 +1,27 @@
#include "..\script_component.hpp"
/*
* File: fnc_openUI.sqf
* Author: IDSolutions
* Date: 2026-01-28
* Last Update: 2026-01-30
* Public: No
*
* Description:
* Opens the player interaction interface.
*
* Arguments:
* None
*
* Return Value:
* None
* UI opened [BOOL]
*
* Example:
* [] call forge_client_actor_fnc_openUI;
*
* Public: No
* call forge_client_actor_fnc_openUI;
*/
private _display = (findDisplay 46) createDisplay "RscActorMenu";
private _ctrl = (_display displayCtrl 1001);
private _display = createDialog ["RscActorMenu", true];
private _ctrl = _display displayCtrl 1001;
_ctrl ctrlAddEventHandler ["JSDialog", {
params ["_control", "_isConfirmDialog", "_message"];

View File

@ -1,70 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interaction Menu</title>
<link rel="stylesheet" href="style.css" />
<!--
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interaction Menu</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
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
),
]).then(([css, js]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
),
]).then(([css, js]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
</script>
</head>
<body>
<div class="container">
<div class="neu-menu">
<div class="neu-menu-content">
<div class="neu-menu-grid" id="menuGrid"></div>
</div>
</div>
</div>
<script src="script.js"></script>
<script>
function updateState() {
if (typeof store !== "undefined") {
const state = store.getState();
const menuGrid = document.getElementById("menuGrid");
if (state.menuItems.length === 0) {
if (menuGrid) menuGrid.style.display = "none";
} else {
if (menuGrid) menuGrid.style.display = "grid";
}
}
}
setTimeout(() => {
if (typeof store !== "undefined") {
store.subscribe((state) => {
updateState();
});
updateState();
}
}, 1000);
</script>
</body>
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
</script>
</head>
<body>
<div id="app"></div>
<!-- <script src="script.js"></script> -->
</body>
</html>

View File

@ -1,12 +1,66 @@
/**
* Redux-like Pattern for Actor Menu Management
* Interaction Menu - Modern UI Implementation
* Uses vanilla JS with React-like patterns and Redux-like state management
*/
//=============================================================================
// #region LIBRARY - DOM Helper & State Management
//=============================================================================
// Helper to create DOM elements (React-like createElement)
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
if (props) {
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.substring(2).toLowerCase(), value);
} else if (key === "className") {
el.className = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value);
} else {
el.setAttribute(key, value);
}
});
}
children.forEach((child) => {
if (typeof child === "string" || typeof child === "number") {
el.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
el.appendChild(child);
} else if (Array.isArray(child)) {
child.forEach((c) => {
if (c instanceof Node) el.appendChild(c);
});
}
});
return el;
}
// Simple Rendering Logic
let _rootContainer = null;
let _rootComponent = null;
function render(component, container) {
_rootContainer = container;
_rootComponent = component;
_render();
}
function _render() {
if (_rootContainer && _rootComponent) {
_rootContainer.innerHTML = "";
_rootContainer.appendChild(_rootComponent());
}
}
//=============================================================================
// #region ACTIONS
//=============================================================================
// Action Types
const ActionTypes = {
SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS",
SET_MENU_ITEMS: "SET_MENU_ITEMS",
@ -15,7 +69,6 @@ const ActionTypes = {
CLEAR_ACTIONS: "CLEAR_ACTIONS",
};
// Action Creators
const actions = {
setAvailableActions: (actionTypes) => ({
type: ActionTypes.SET_AVAILABLE_ACTIONS,
@ -47,84 +100,91 @@ const actions = {
//=============================================================================
const baseMenuItems = [
{
id: "atm",
title: "ATM",
description: "Access the ATM",
icon: "",
action: "actor::open::atm",
},
{
id: "bank",
title: "Banking Services",
description: "Access your bank account and manage finances",
icon: "",
action: "actor::open::bank",
},
{
id: "phone",
title: "Personal Phone",
title: "Phone",
description: "Access and manage your personal phone",
icon: "",
action: "actor::open::phone",
},
{
id: "org",
title: "Organization Dashboard",
title: "Organization",
description: "View and manage your organization data",
icon: "",
action: "actor::open::org",
},
{
id: "store",
title: "Store",
description: "Browse and purchase items from the store",
action: "actor::open::store",
},
];
const actionDefinitions = {
atm: {
id: "atm",
title: "ATM",
description: "Access the ATM",
action: "actor::open::atm",
},
bank: {
id: "bank",
title: "Bank",
description: "Access your bank account and manage finances",
action: "actor::open::bank",
},
phone: {
id: "phone",
title: "Phone",
description: "Access and manage your personal phone",
action: "actor::open::phone",
},
org: {
id: "org",
title: "Organization",
description: "View and manage your organization data",
action: "actor::open::org",
},
store: {
id: "store",
title: "Store",
description: "Browse and purchase items from the store",
action: "actor::open::store",
},
device: {
id: "device",
title: "Device Interaction",
title: "Device",
description: "Manage devices and settings",
icon: "",
action: "actor::open::device",
},
garage: {
id: "garage",
title: "Vehicle Garage",
title: "Garage",
description: "Access and manage your vehicle collection",
icon: "",
action: "actor::open::garage",
},
locker: {
id: "locker",
title: "Locker",
description: "Access your personal locker for storage",
icon: "",
action: "actor::open::locker",
},
player: {
id: "player",
title: "Player Interaction",
title: "Player",
description: "Interact with player-specific actions",
icon: "",
action: "actor::open::iplayer",
},
store: {
id: "store",
title: "Store",
description: "Browse and purchase items from the store",
icon: "",
action: "actor::open::store",
},
va: {
id: "va",
title: "Virtual Arsenal",
title: "Arsenal",
description: "Access your virtual arsenal",
icon: "",
action: "actor::open::vlocker",
},
vg: {
id: "vg",
title: "Virtual Garage",
title: "V. Garage",
description: "Access your virtual garage",
icon: "",
action: "actor::open::vgarage",
},
};
@ -141,7 +201,6 @@ function actorReducer(state = initialState, action) {
case ActionTypes.SET_AVAILABLE_ACTIONS:
const newMenuItems = [...state.baseMenuItems];
// Process available actions
const actionArray = Array.isArray(action.payload)
? action.payload
: [];
@ -225,6 +284,7 @@ class Store {
console.log("Dispatching action:", action);
this.state = this.reducer(this.state, action);
this.listeners.forEach((listener) => listener(this.state));
_render(); // Re-render on state change
}
subscribe(listener) {
@ -235,7 +295,6 @@ class Store {
}
}
// Create store instance
const store = new Store(actorReducer, initialState);
//=============================================================================
@ -253,94 +312,149 @@ const selectors = {
};
//=============================================================================
// #region UI COMPONENTS (Redux-connected)
// #region UI COMPONENTS
//=============================================================================
class ActorUI {
constructor(store) {
this.store = store;
this.unsubscribe = null;
// Tooltip state
let tooltipEl = null;
function createTooltip() {
if (!tooltipEl) {
tooltipEl = h(
"div",
{ className: "radial-tooltip" },
h("div", { className: "tooltip-title" }),
h("div", { className: "tooltip-description" }),
);
document.body.appendChild(tooltipEl);
}
return tooltipEl;
}
init() {
console.log("ActorUI initializing...");
function showTooltip(item, x, y) {
const tooltip = createTooltip();
tooltip.querySelector(".tooltip-title").textContent = item.title;
tooltip.querySelector(".tooltip-description").textContent =
item.description;
tooltip.style.left = `${x + 15}px`;
tooltip.style.top = `${y + 10}px`;
tooltip.classList.add("visible");
}
// Subscribe to state changes
this.unsubscribe = this.store.subscribe((state) => {
this.render(state);
});
// Initial render
this.render(this.store.getState());
// Request initial data
this.requestInitialData();
console.log("ActorUI initialized successfully");
function hideTooltip() {
if (tooltipEl) {
tooltipEl.classList.remove("visible");
}
}
render(state) {
this.updateMenuDisplay(state);
}
function RadialItem({ item, index, total, onClick }) {
const menuRadius = 160;
const itemSize = 80;
updateMenuDisplay(state) {
const grid = document.getElementById("menuGrid");
if (!grid) {
console.error("Menu grid element not found");
return;
// Calculate position in circle
const angleStep = (2 * Math.PI) / total;
const angle = angleStep * index - Math.PI / 2; // Start from top
const centerX = menuRadius + itemSize / 2;
const centerY = menuRadius + itemSize / 2;
const x = centerX + menuRadius * Math.cos(angle) - itemSize / 2;
const y = centerY + menuRadius * Math.sin(angle) - itemSize / 2;
const el = h(
"div",
{
className: "radial-item",
style: {
left: `${x}px`,
top: `${y}px`,
},
onClick: () => onClick(item),
},
h("div", { className: "radial-item-title" }, item.title),
);
// Add tooltip events
el.addEventListener("mouseenter", (e) =>
showTooltip(item, e.clientX, e.clientY),
);
el.addEventListener("mousemove", (e) => {
if (tooltipEl && tooltipEl.classList.contains("visible")) {
tooltipEl.style.left = `${e.clientX + 15}px`;
tooltipEl.style.top = `${e.clientY + 10}px`;
}
});
el.addEventListener("mouseleave", hideTooltip);
// Clear existing menu items
grid.innerHTML = "";
return el;
}
// Render menu items
const menuItems = selectors.getMenuItems(state);
menuItems.forEach((item) => {
const menuItem = document.createElement("div");
menuItem.className = "neu-menu-item";
menuItem.setAttribute("data-action", item.action);
menuItem.innerHTML = `
<div class="neu-menu-item-icon">${item.icon}</div>
<div class="neu-menu-item-title">${item.title}</div>
<div class="neu-menu-item-description">${item.description}</div>
`;
menuItem.addEventListener("click", () =>
this.handleMenuItemClick(item),
);
function RadialCenter({ onClose }) {
return h(
"div",
{
className: "radial-center",
onClick: onClose,
},
h("div", { className: "center-label" }, "Close"),
);
}
grid.appendChild(menuItem);
});
function RadialMenu() {
const state = store.getState();
const menuItems = selectors.getMenuItems(state);
console.log(`Rendered ${menuItems.length} menu items`);
}
handleMenuItemClick(item) {
const handleItemClick = (item) => {
console.log("Menu item clicked:", item);
const alert = {
event: item.action,
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
if (typeof A3API !== "undefined") {
A3API.SendAlert(JSON.stringify(alert));
}
};
requestInitialData() {
console.log("Requesting initial actor data...");
const handleClose = () => {
console.log("Close menu requested");
const alert = {
event: "actor::get::actions",
event: "actor::close::menu",
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
if (typeof A3API !== "undefined") {
A3API.SendAlert(JSON.stringify(alert));
}
};
if (menuItems.length === 0) {
return h(
"div",
{ className: "empty-state" },
h("p", null, "No actions available"),
);
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
return h(
"div",
{ className: "radial-menu" },
RadialCenter({ onClose: handleClose }),
menuItems.map((item, index) =>
RadialItem({
item,
index,
total: menuItems.length,
onClick: handleItemClick,
}),
),
);
}
function App() {
return RadialMenu();
}
//=============================================================================
// #region DATA HANDLERS (Redux-connected)
// #region DATA HANDLERS (A3API Integration)
//=============================================================================
function updateAvailableActions(actionTypes) {
@ -354,78 +468,45 @@ function handleGetActionsResponse(data) {
}
//=============================================================================
// #region ACTION HANDLERS
// #region INITIALIZATION
//=============================================================================
function handleMenuItemClick(item) {
console.log("Legacy menu item click handler:", item);
const alert = {
event: item.action,
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
let initialized = false;
//=============================================================================
// #region INITIALIZATION FUNCTIONS
//=============================================================================
// Global flag to prevent double initialization
let actorUIInitialized = false;
/**
* Initialize the actor interface - called from HTML after script loads
*/
function initializeMenu() {
console.log("initializeMenu() called");
if (actorUIInitialized) {
console.log("ActorUI already initialized, skipping...");
if (initialized) {
console.log("Menu already initialized, skipping...");
return;
}
// Check if DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
if (!actorUIInitialized) {
console.log("DOM loaded, initializing ActorUI...");
window.actorUI = new ActorUI(store);
window.actorUI.init();
actorUIInitialized = true;
}
});
const root = document.getElementById("app");
if (root) {
render(App, root);
initialized = true;
console.log("Interaction menu initialized successfully");
// Request initial data from A3API
if (typeof A3API !== "undefined") {
const alert = {
event: "actor::get::actions",
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
} else {
// DOM is already ready
console.log("DOM already ready, initializing ActorUI...");
window.actorUI = new ActorUI(store);
window.actorUI.init();
actorUIInitialized = true;
console.error("Root element #app not found");
}
}
//=============================================================================
// #region GLOBAL VARIABLES
//=============================================================================
// Make actorUI globally accessible
let actorUI;
// Auto-initialize if DOM is already loaded when script executes
// Auto-initialize based on DOM state
if (document.readyState !== "loading") {
console.log("Script loaded after DOM ready, auto-initializing...");
if (!actorUIInitialized) {
actorUI = new ActorUI(store);
actorUI.init();
actorUIInitialized = true;
}
initializeMenu();
} else {
// Wait for DOM to be ready
document.addEventListener("DOMContentLoaded", () => {
if (!actorUIInitialized) {
console.log("DOM loaded, initializing ActorUI...");
actorUI = new ActorUI(store);
actorUI.init();
actorUIInitialized = true;
}
console.log("DOM loaded, initializing menu...");
initializeMenu();
});
}

View File

@ -1,417 +0,0 @@
/**
* Redux-like Pattern for Actor Menu Management
*/
//=============================================================================
// #region ACTIONS
//=============================================================================
// Action Types
const ActionTypes = {
SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS",
SET_MENU_ITEMS: "SET_MENU_ITEMS",
ADD_ACTION: "ADD_ACTION",
REMOVE_ACTION: "REMOVE_ACTION",
CLEAR_ACTIONS: "CLEAR_ACTIONS",
};
// Action Creators
const actions = {
setAvailableActions: (actionTypes) => ({
type: ActionTypes.SET_AVAILABLE_ACTIONS,
payload: actionTypes,
}),
setMenuItems: (menuItems) => ({
type: ActionTypes.SET_MENU_ITEMS,
payload: menuItems,
}),
addAction: (actionType) => ({
type: ActionTypes.ADD_ACTION,
payload: actionType,
}),
removeAction: (actionType) => ({
type: ActionTypes.REMOVE_ACTION,
payload: actionType,
}),
clearActions: () => ({
type: ActionTypes.CLEAR_ACTIONS,
}),
};
//=============================================================================
// #region REDUCER
//=============================================================================
const baseMenuItems = [
{
id: "bank",
title: "Banking Services",
description: "Access your bank account and manage finances",
icon: "",
action: "actor::open::bank",
},
{
id: "phone",
title: "Personal Phone",
description: "Access and manage your personal phone",
icon: "",
action: "actor::open::phone",
},
];
const actionDefinitions = {
device: {
id: "device",
title: "Device Interaction",
description: "Manage devices and settings",
icon: "",
action: "actor::open::device",
},
garage: {
id: "garage",
title: "Vehicle Garage",
description: "Access and manage your vehicle collection",
icon: "",
action: "actor::open::garage",
},
locker: {
id: "locker",
title: "Locker",
description: "Access your personal locker for storage",
icon: "",
action: "actor::open::locker",
},
player: {
id: "player",
title: "Player Interaction",
description: "Interact with player-specific actions",
icon: "",
action: "actor::open::iplayer",
},
store: {
id: "store",
title: "Store",
description: "Browse and purchase items from the store",
icon: "",
action: "actor::open::store",
},
va: {
id: "va",
title: "Virtual Arsenal",
description: "Access your virtual arsenal",
icon: "",
action: "actor::open::arsenal",
},
vg: {
id: "vg",
title: "Virtual Garage",
description: "Access your virtual garage",
icon: "",
action: "actor::open::vgarage",
},
};
const initialState = {
availableActions: [],
menuItems: [...baseMenuItems],
baseMenuItems: [...baseMenuItems],
actionDefinitions: { ...actionDefinitions },
};
function actorReducer(state = initialState, action) {
switch (action.type) {
case ActionTypes.SET_AVAILABLE_ACTIONS:
const newMenuItems = [...state.baseMenuItems];
// Process available actions
const actionArray = Array.isArray(action.payload)
? action.payload
: [];
actionArray.forEach((actionItem) => {
if (Array.isArray(actionItem) && actionItem.length === 2) {
const [type, value] = actionItem;
const definition = state.actionDefinitions[value];
if (definition) {
newMenuItems.push(definition);
} else {
console.warn(
`No definition found for: ${type} - ${value}`,
);
}
} else {
console.warn("Invalid action format:", actionItem);
}
});
return {
...state,
availableActions: action.payload,
menuItems: newMenuItems,
};
case ActionTypes.SET_MENU_ITEMS:
return {
...state,
menuItems: action.payload,
};
case ActionTypes.ADD_ACTION:
const definition = state.actionDefinitions[action.payload];
if (
definition &&
!state.menuItems.find((item) => item.id === definition.id)
) {
return {
...state,
menuItems: [...state.menuItems, definition],
};
}
return state;
case ActionTypes.REMOVE_ACTION:
return {
...state,
menuItems: state.menuItems.filter(
(item) => item.id !== action.payload,
),
};
case ActionTypes.CLEAR_ACTIONS:
return {
...state,
availableActions: [],
menuItems: [...state.baseMenuItems],
};
default:
return state;
}
}
//=============================================================================
// #region STORE
//=============================================================================
class Store {
constructor(reducer, initialState) {
this.reducer = reducer;
this.state = initialState;
this.listeners = [];
}
getState() {
return this.state;
}
dispatch(action) {
console.log("Dispatching action:", action);
this.state = this.reducer(this.state, action);
this.listeners.forEach((listener) => listener(this.state));
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
}
// Create store instance
const store = new Store(actorReducer, initialState);
//=============================================================================
// #region SELECTORS
//=============================================================================
const selectors = {
getMenuItems: (state) => state.menuItems,
getAvailableActions: (state) => state.availableActions,
getBaseMenuItems: (state) => state.baseMenuItems,
getActionDefinitions: (state) => state.actionDefinitions,
getMenuItemById: (state, id) =>
state.menuItems.find((item) => item.id === id),
getMenuItemsCount: (state) => state.menuItems.length,
};
//=============================================================================
// #region UI COMPONENTS (Redux-connected)
//=============================================================================
class ActorUI {
constructor(store) {
this.store = store;
this.unsubscribe = null;
}
init() {
console.log("ActorUI initializing...");
// Subscribe to state changes
this.unsubscribe = this.store.subscribe((state) => {
this.render(state);
});
// Initial render
this.render(this.store.getState());
// Request initial data
this.requestInitialData();
console.log("ActorUI initialized successfully");
}
render(state) {
this.updateMenuDisplay(state);
}
updateMenuDisplay(state) {
const grid = document.getElementById("menuGrid");
if (!grid) {
console.error("Menu grid element not found");
return;
}
// Clear existing menu items
grid.innerHTML = "";
// Render menu items
const menuItems = selectors.getMenuItems(state);
menuItems.forEach((item) => {
const menuItem = document.createElement("div");
menuItem.className = "neu-menu-item";
menuItem.setAttribute("data-action", item.action);
menuItem.innerHTML = `
<div class="neu-menu-item-icon">${item.icon}</div>
<div class="neu-menu-item-title">${item.title}</div>
<div class="neu-menu-item-description">${item.description}</div>
`;
menuItem.addEventListener("click", () =>
this.handleMenuItemClick(item),
);
grid.appendChild(menuItem);
});
console.log(`Rendered ${menuItems.length} menu items`);
}
handleMenuItemClick(item) {
console.log("Menu item clicked:", item);
const alert = {
event: item.action,
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
requestInitialData() {
console.log("Requesting initial actor data...");
const alert = {
event: "actor::get::actions",
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
//=============================================================================
// #region DATA HANDLERS (Redux-connected)
//=============================================================================
function updateAvailableActions(actionTypes) {
console.log("Updating available actions:", actionTypes);
store.dispatch(actions.setAvailableActions(actionTypes));
}
function handleGetActionsResponse(data) {
console.log("Received actions data:", data);
store.dispatch(actions.setAvailableActions(data));
}
//=============================================================================
// #region ACTION HANDLERS
//=============================================================================
function handleMenuItemClick(item) {
console.log("Legacy menu item click handler:", item);
const alert = {
event: item.action,
data: {},
};
A3API.SendAlert(JSON.stringify(alert));
}
//=============================================================================
// #region INITIALIZATION FUNCTIONS
//=============================================================================
// Global flag to prevent double initialization
let actorUIInitialized = false;
/**
* Initialize the actor interface - called from HTML after script loads
*/
function initializeMenu() {
console.log("initializeMenu() called");
if (actorUIInitialized) {
console.log("ActorUI already initialized, skipping...");
return;
}
// Check if DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
if (!actorUIInitialized) {
console.log("DOM loaded, initializing ActorUI...");
window.actorUI = new ActorUI(store);
window.actorUI.init();
actorUIInitialized = true;
}
});
} else {
// DOM is already ready
console.log("DOM already ready, initializing ActorUI...");
window.actorUI = new ActorUI(store);
window.actorUI.init();
actorUIInitialized = true;
}
}
//=============================================================================
// #region GLOBAL VARIABLES
//=============================================================================
// Make actorUI globally accessible
let actorUI;
// Auto-initialize if DOM is already loaded when script executes
if (document.readyState !== "loading") {
console.log("Script loaded after DOM ready, auto-initializing...");
if (!actorUIInitialized) {
actorUI = new ActorUI(store);
actorUI.init();
actorUIInitialized = true;
}
} else {
// Wait for DOM to be ready
document.addEventListener("DOMContentLoaded", () => {
if (!actorUIInitialized) {
console.log("DOM loaded, initializing ActorUI...");
actorUI = new ActorUI(store);
actorUI.init();
actorUIInitialized = true;
}
});
}

View File

@ -1,567 +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;
}
.store-container {
height: 100vh;
width: 100vw;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header Section */
.store-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);
}
.store-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;
}
.store-info {
flex: 1;
}
.store-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;
}
.store-subtitle {
font-size: 0.875rem;
color: rgba(140, 160, 180, 0.8);
letter-spacing: 0.5px;
}
.balance-display {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
padding: 0.75rem 1.25rem;
background: rgba(20, 30, 45, 0.7);
border: 1px solid rgba(100, 200, 150, 0.4);
border-radius: 4px;
}
.balance-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.8);
}
.balance-amount {
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);
}
.cart-btn {
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
}
.cart-icon {
font-size: 1.25rem;
}
.cart-count {
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: rgba(100, 150, 200, 0.3);
border: 1px solid rgba(100, 150, 200, 0.5);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.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 */
.store-content {
flex: 1;
display: grid;
grid-template-columns: 250px 1fr;
gap: 1.5rem;
overflow: hidden;
}
.store-content.cart-open {
grid-template-columns: 250px 1fr 350px;
}
/* Panels */
.store-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);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.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);
}
/* Search Box */
.search-box {
flex: 1;
max-width: 300px;
}
.search-input {
width: 100%;
padding: 0.625rem 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);
}
/* Category List */
.category-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.category-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
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;
color: rgba(200, 220, 240, 0.95);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.category-item:hover {
background: rgba(30, 45, 70, 0.7);
border-left-color: rgba(150, 200, 255, 0.7);
}
.category-item.active {
background: rgba(30, 45, 70, 0.8);
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);
}
.category-icon {
font-size: 1.5rem;
}
.category-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.category-count {
padding: 0.25rem 0.5rem;
background: rgba(100, 150, 200, 0.2);
border: 1px solid rgba(100, 150, 200, 0.3);
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
/* Items Grid */
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.item-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;
}
.item-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);
}
.item-icon {
font-size: 3rem;
text-align: center;
}
.item-name {
font-size: 0.95rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
text-align: center;
}
.item-description {
font-size: 0.75rem;
color: rgba(140, 160, 180, 0.85);
text-align: center;
line-height: 1.3;
min-height: 2.6rem;
}
.item-price {
font-size: 1.125rem;
font-weight: 600;
color: rgba(100, 200, 150, 1);
text-align: center;
margin-top: auto;
}
.item-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.add-to-cart-btn {
flex: 1;
padding: 0.625rem;
background: rgba(100, 150, 200, 0.2);
border: 1px solid rgba(100, 150, 200, 0.4);
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;
}
.add-to-cart-btn:hover {
background: rgba(100, 150, 200, 0.3);
border-color: rgba(150, 200, 255, 0.6);
}
/* Cart Panel */
.cart-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.empty-cart {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 1rem;
}
.empty-icon {
font-size: 3rem;
opacity: 0.3;
}
.empty-text {
font-size: 0.875rem;
color: rgba(140, 160, 180, 0.7);
}
.cart-item {
padding: 1rem;
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;
}
.cart-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.cart-item-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
}
.cart-item-remove {
padding: 0.25rem 0.5rem;
background: rgba(200, 100, 100, 0.2);
border: 1px solid rgba(200, 100, 100, 0.4);
border-radius: 3px;
color: rgba(255, 150, 150, 0.9);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.cart-item-remove:hover {
background: rgba(200, 100, 100, 0.3);
}
.cart-item-details {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: rgba(160, 180, 200, 0.85);
}
.cart-item-price {
color: rgba(100, 200, 150, 1);
font-weight: 600;
}
.clear-cart-btn {
padding: 0.5rem 0.75rem;
background: rgba(200, 100, 100, 0.2);
border: 1px solid rgba(200, 100, 100, 0.4);
border-radius: 4px;
color: rgba(255, 150, 150, 0.9);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.clear-cart-btn:hover {
background: rgba(200, 100, 100, 0.3);
}
/* Cart Summary */
.cart-summary {
padding-top: 1.5rem;
border-top: 1px solid rgba(100, 150, 200, 0.2);
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.summary-label {
font-size: 0.875rem;
color: rgba(160, 180, 200, 0.85);
}
.summary-value {
font-size: 0.95rem;
font-weight: 600;
color: rgba(200, 220, 240, 0.95);
}
.summary-total {
padding-top: 0.75rem;
border-top: 1px solid rgba(100, 150, 200, 0.2);
margin-top: 0.5rem;
}
.summary-total .summary-label {
font-size: 1rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
}
.summary-total .summary-value {
font-size: 1.25rem;
color: rgba(100, 200, 150, 1);
}
.action-btn-primary {
width: 100%;
padding: 0.875rem;
margin-top: 1rem;
background: rgba(100, 150, 200, 0.2);
border: 1px solid rgba(100, 150, 200, 0.5);
}
.action-btn-primary:hover {
background: rgba(100, 150, 200, 0.3);
border-color: rgba(150, 200, 255, 0.7);
}
.checkout-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkout-btn:disabled:hover {
background: rgba(100, 150, 200, 0.2);
border-color: rgba(100, 150, 200, 0.5);
box-shadow: none;
}
/* Responsive adjustments */
@media (max-width: 1400px) {
.items-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
@media (max-width: 1200px) {
.store-content {
grid-template-columns: 1fr;
}
.store-content.cart-open {
grid-template-columns: 1fr 350px;
}
.categories-panel {
display: none;
}
}

View File

@ -1,149 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Store</title>
<link rel="stylesheet" href="store.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
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\store.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\actor\\ui\\_site\\store.js",
),
]).then(([css, js]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
</script>
</head>
<body>
<div class="store-container">
<!-- Header Section -->
<div class="store-header">
<div class="store-logo">
<div class="logo-icon">🛒</div>
</div>
<div class="store-info">
<h1 class="store-title">Supply Store</h1>
<p class="store-subtitle">Equipment & Resources</p>
</div>
<div class="balance-display">
<span class="balance-label">Available Funds</span>
<span class="balance-amount">$45,750</span>
</div>
<div class="header-actions">
<button class="action-btn cart-btn" id="cartToggle">
<span class="cart-icon">🛒</span>
<span class="cart-count">0</span>
</button>
<button class="action-btn close-btn">Close</button>
</div>
</div>
<!-- Main Content -->
<div class="store-content">
<!-- Left Panel - Categories -->
<div class="store-panel categories-panel">
<div class="panel-header">
<h2 class="panel-title">Categories</h2>
</div>
<div class="panel-content">
<div class="category-list">
<button class="category-item active" data-category="all">
<span class="category-icon">📦</span>
<span class="category-name">All Items</span>
<span class="category-count">24</span>
</button>
<button class="category-item" data-category="weapons">
<span class="category-icon">🔫</span>
<span class="category-name">Weapons</span>
<span class="category-count">8</span>
</button>
<button class="category-item" data-category="equipment">
<span class="category-icon">🎽</span>
<span class="category-name">Equipment</span>
<span class="category-count">6</span>
</button>
<button class="category-item" data-category="medical">
<span class="category-icon">💊</span>
<span class="category-name">Medical</span>
<span class="category-count">5</span>
</button>
<button class="category-item" data-category="supplies">
<span class="category-icon">📦</span>
<span class="category-name">Supplies</span>
<span class="category-count">5</span>
</button>
</div>
</div>
</div>
<!-- Center Panel - Items Grid -->
<div class="store-panel items-panel">
<div class="panel-header">
<h2 class="panel-title">Available Items</h2>
<div class="search-box">
<input type="text" class="search-input" placeholder="Search items..." id="searchInput">
</div>
</div>
<div class="panel-content">
<div class="items-grid" id="itemsGrid">
<!-- Items will be dynamically generated -->
</div>
</div>
</div>
<!-- Right Panel - Cart (Initially Hidden) -->
<div class="store-panel cart-panel" id="cartPanel" style="display: none;">
<div class="panel-header">
<h2 class="panel-title">Shopping Cart</h2>
<button class="clear-cart-btn" id="clearCart">Clear</button>
</div>
<div class="panel-content">
<div class="cart-items" id="cartItems">
<div class="empty-cart">
<span class="empty-icon">🛒</span>
<span class="empty-text">Your cart is empty</span>
</div>
</div>
<div class="cart-summary">
<div class="summary-row">
<span class="summary-label">Subtotal</span>
<span class="summary-value" id="cartSubtotal">$0</span>
</div>
<div class="summary-row">
<span class="summary-label">Tax (5%)</span>
<span class="summary-value" id="cartTax">$0</span>
</div>
<div class="summary-row summary-total">
<span class="summary-label">Total</span>
<span class="summary-value" id="cartTotal">$0</span>
</div>
<button class="action-btn action-btn-primary checkout-btn" id="checkoutBtn">
Complete Purchase
</button>
</div>
</div>
</div>
</div>
</div>
<script src="store.js"></script>
</body>
</html>

View File

@ -1,339 +0,0 @@
/**
* Store Interface
* Handles item browsing, cart management, and purchases
*/
// Mock data
const mockData = {
balance: 45750,
items: [
// Weapons
{ id: 1, name: "Assault Rifle", category: "weapons", icon: "🔫", description: "Standard military-grade rifle", price: 2500 },
{ id: 2, name: "Sniper Rifle", category: "weapons", icon: "🎯", description: "Long-range precision weapon", price: 4500 },
{ id: 3, name: "SMG", category: "weapons", icon: "🔫", description: "Close-quarters combat", price: 1800 },
{ id: 4, name: "Pistol", category: "weapons", icon: "🔫", description: "Sidearm backup weapon", price: 800 },
{ id: 5, name: "Shotgun", category: "weapons", icon: "🔫", description: "Close-range powerhouse", price: 1500 },
{ id: 6, name: "LMG", category: "weapons", icon: "🔫", description: "Heavy suppression weapon", price: 3500 },
{ id: 7, name: "Grenade Launcher", category: "weapons", icon: "💣", description: "Explosive ordnance", price: 5000 },
{ id: 8, name: "Rocket Launcher", category: "weapons", icon: "🚀", description: "Anti-vehicle weapon", price: 8000 },
// Equipment
{ id: 9, name: "Body Armor", category: "equipment", icon: "🎽", description: "Ballistic protection", price: 3000 },
{ id: 10, name: "Helmet", category: "equipment", icon: "⛑️", description: "Head protection", price: 1200 },
{ id: 11, name: "Night Vision", category: "equipment", icon: "🕶️", description: "See in the dark", price: 2500 },
{ id: 12, name: "GPS Device", category: "equipment", icon: "📡", description: "Navigation system", price: 800 },
{ id: 13, name: "Radio", category: "equipment", icon: "📻", description: "Team communication", price: 600 },
{ id: 14, name: "Backpack", category: "equipment", icon: "🎒", description: "Extra storage capacity", price: 500 },
// Medical
{ id: 15, name: "First Aid Kit", category: "medical", icon: "💊", description: "Basic medical supplies", price: 400 },
{ id: 16, name: "Med Kit", category: "medical", icon: "⚕️", description: "Advanced medical kit", price: 1000 },
{ id: 17, name: "Bandages", category: "medical", icon: "🩹", description: "Stop bleeding", price: 150 },
{ id: 18, name: "Morphine", category: "medical", icon: "💉", description: "Pain management", price: 300 },
{ id: 19, name: "Blood Bag", category: "medical", icon: "🩸", description: "Restore blood level", price: 500 },
// Supplies
{ id: 20, name: "Ammunition Box", category: "supplies", icon: "📦", description: "Mixed ammunition", price: 800 },
{ id: 21, name: "Explosive Charges", category: "supplies", icon: "💣", description: "Demolition supplies", price: 1500 },
{ id: 22, name: "Toolkit", category: "supplies", icon: "🔧", description: "Repair equipment", price: 600 },
{ id: 23, name: "Food Rations", category: "supplies", icon: "🥫", description: "Emergency supplies", price: 200 },
{ id: 24, name: "Water Canteen", category: "supplies", icon: "🧃", description: "Hydration supply", price: 150 }
]
};
// State
let cart = [];
let selectedCategory = 'all';
let searchQuery = '';
// Initialize
function initStore() {
console.log('Store interface initializing...');
setupEventHandlers();
renderItems();
updateBalance();
console.log('Store interface initialized');
}
// Event Handlers
function setupEventHandlers() {
// Close button
const closeBtn = document.querySelector('.close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
console.log('Closing store...');
sendEvent('actor::close::store', {});
});
}
// Cart toggle
const cartToggle = document.getElementById('cartToggle');
const cartPanel = document.getElementById('cartPanel');
const storeContent = document.querySelector('.store-content');
if (cartToggle && cartPanel) {
cartToggle.addEventListener('click', () => {
const isOpen = cartPanel.style.display !== 'none';
cartPanel.style.display = isOpen ? 'none' : 'flex';
storeContent.classList.toggle('cart-open', !isOpen);
});
}
// Category filters
const categoryItems = document.querySelectorAll('.category-item');
categoryItems.forEach(item => {
item.addEventListener('click', () => {
categoryItems.forEach(c => c.classList.remove('active'));
item.classList.add('active');
selectedCategory = item.dataset.category;
renderItems();
});
});
// Search
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
searchQuery = e.target.value.toLowerCase();
renderItems();
});
}
// Clear cart
const clearCartBtn = document.getElementById('clearCart');
if (clearCartBtn) {
clearCartBtn.addEventListener('click', () => {
if (confirm('Clear all items from cart?')) {
cart = [];
renderCart();
updateCartCount();
}
});
}
// Checkout
const checkoutBtn = document.getElementById('checkoutBtn');
if (checkoutBtn) {
checkoutBtn.addEventListener('click', handleCheckout);
}
}
// Render items
function renderItems() {
const itemsGrid = document.getElementById('itemsGrid');
if (!itemsGrid) return;
itemsGrid.innerHTML = '';
// Filter items
let filteredItems = mockData.items;
if (selectedCategory !== 'all') {
filteredItems = filteredItems.filter(item => item.category === selectedCategory);
}
if (searchQuery) {
filteredItems = filteredItems.filter(item =>
item.name.toLowerCase().includes(searchQuery) ||
item.description.toLowerCase().includes(searchQuery)
);
}
// Render filtered items
filteredItems.forEach(item => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-icon">${item.icon}</div>
<div class="item-name">${item.name}</div>
<div class="item-description">${item.description}</div>
<div class="item-price">$${item.price.toLocaleString()}</div>
<div class="item-actions">
<button class="add-to-cart-btn" data-item-id="${item.id}">Add to Cart</button>
</div>
`;
const addBtn = card.querySelector('.add-to-cart-btn');
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
addToCart(item);
});
itemsGrid.appendChild(card);
});
console.log(`Rendered ${filteredItems.length} items`);
}
// Cart functions
function addToCart(item) {
const existingItem = cart.find(c => c.id === item.id);
if (existingItem) {
existingItem.quantity++;
} else {
cart.push({ ...item, quantity: 1 });
}
renderCart();
updateCartCount();
// Show cart panel if not visible
const cartPanel = document.getElementById('cartPanel');
const storeContent = document.querySelector('.store-content');
if (cartPanel.style.display === 'none') {
cartPanel.style.display = 'flex';
storeContent.classList.add('cart-open');
}
console.log('Added to cart:', item.name);
}
function removeFromCart(itemId) {
cart = cart.filter(item => item.id !== itemId);
renderCart();
updateCartCount();
}
function renderCart() {
const cartItems = document.getElementById('cartItems');
if (!cartItems) return;
cartItems.innerHTML = '';
if (cart.length === 0) {
cartItems.innerHTML = `
<div class="empty-cart">
<span class="empty-icon">🛒</span>
<span class="empty-text">Your cart is empty</span>
</div>
`;
} else {
cart.forEach(item => {
const cartItem = document.createElement('div');
cartItem.className = 'cart-item';
cartItem.innerHTML = `
<div class="cart-item-header">
<span class="cart-item-name">${item.name}</span>
<button class="cart-item-remove" data-item-id="${item.id}">Remove</button>
</div>
<div class="cart-item-details">
<span>Qty: ${item.quantity}</span>
<span class="cart-item-price">$${(item.price * item.quantity).toLocaleString()}</span>
</div>
`;
const removeBtn = cartItem.querySelector('.cart-item-remove');
removeBtn.addEventListener('click', () => removeFromCart(item.id));
cartItems.appendChild(cartItem);
});
}
updateCartSummary();
}
function updateCartCount() {
const cartCount = document.querySelector('.cart-count');
if (cartCount) {
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
cartCount.textContent = totalItems;
}
}
function updateCartSummary() {
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const tax = subtotal * 0.05;
const total = subtotal + tax;
document.getElementById('cartSubtotal').textContent = `$${subtotal.toLocaleString()}`;
document.getElementById('cartTax').textContent = `$${tax.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
document.getElementById('cartTotal').textContent = `$${total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const checkoutBtn = document.getElementById('checkoutBtn');
if (checkoutBtn) {
checkoutBtn.disabled = cart.length === 0 || total > mockData.balance;
}
}
function handleCheckout() {
const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const tax = total * 0.05;
const grandTotal = total + tax;
if (grandTotal > mockData.balance) {
alert('Insufficient funds!');
return;
}
const purchaseData = {
items: cart.map(item => ({
id: item.id,
name: item.name,
quantity: item.quantity,
price: item.price
})),
subtotal: total,
tax: tax,
total: grandTotal
};
console.log('Purchase request:', purchaseData);
sendEvent('actor::store::purchase', purchaseData);
// Clear cart after purchase
cart = [];
renderCart();
updateCartCount();
// Update balance (this would normally come from server)
mockData.balance -= grandTotal;
updateBalance();
}
function updateBalance() {
const balanceAmount = document.querySelector('.balance-amount');
if (balanceAmount) {
balanceAmount.textContent = `$${mockData.balance.toLocaleString()}`;
}
}
// Update store data from external source
function updateStoreData(data) {
if (data.balance !== undefined) {
mockData.balance = data.balance;
updateBalance();
}
if (data.items) {
mockData.items = data.items;
renderItems();
}
}
// 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', initStore);
} else {
initStore();
}
// Expose functions globally
window.updateStoreData = updateStoreData;
window.addToCart = addToCart;

View File

@ -1,3 +1,21 @@
:root {
--bg-app: rgba(0, 0, 0, 0.4);
--bg-surface: #ffffff;
--bg-surface-hover: #f1f5f9;
--primary: #475569;
--primary-hover: #1e293b;
--text-main: #1f2937;
--text-muted: #64748b;
--text-inverse: #f8fafc;
--border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--menu-radius: 160px;
--item-size: 80px;
}
* {
margin: 0;
padding: 0;
@ -5,112 +23,168 @@
}
body {
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
height: 100vh;
width: 100vw;
background: rgba(0, 0, 0, 0.5);
font-family: Arial, sans-serif;
background: var(--bg-app);
color: var(--text-main);
line-height: 1.4;
overflow: hidden;
}
.container {
align-items: flex-end;
#app {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Radial Menu Container */
.radial-menu {
position: relative;
width: calc(var(--menu-radius) * 2 + var(--item-size));
height: calc(var(--menu-radius) * 2 + var(--item-size));
}
/* Center Hub */
.radial-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90px;
height: 90px;
background: var(--bg-surface);
border: 2px solid var(--border);
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding-right: 5%;
perspective: 1200px;
}
box-shadow: var(--shadow-lg);
z-index: 10;
cursor: pointer;
transition: all 0.2s ease;
.neu-menu {
background: rgba(15, 20, 30, 0.9);
border: 1px solid rgba(100, 150, 200, 0.4);
border-radius: 4px;
margin-right: 25%;
max-height: 640px;
width: 480px;
transform: rotateY(-10deg) translateZ(0);
transform-style: preserve-3d;
box-shadow:
-5px 0 15px rgba(100, 150, 200, 0.2),
0 8px 32px rgba(0, 0, 0, 0.8);
&:hover {
background: var(--bg-surface-hover);
border-color: var(--primary);
transform: translate(-50%, -50%) scale(1.05);
}
.neu-menu-content {
height: 100%;
overflow: hidden;
padding: 1rem;
.center-icon {
font-size: 1.25rem;
margin-bottom: 0.15rem;
}
.neu-menu-grid {
display: grid;
max-height: 380px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
-webkit-scrollbar-width: thin;
.neu-menu-item {
align-items: flex-start;
background: rgba(20, 30, 45, 0.7);
border-left: 3px solid rgba(100, 150, 200, 0.5);
border-radius: 2px;
color: rgba(200, 220, 240, 0.95);
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 0.5rem;
min-height: 70px;
padding: 0.75rem 1rem;
text-align: left;
transition: all 0.15s ease;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 3px;
background: rgba(100, 150, 200, 0.8);
opacity: 0;
transition: opacity 0.15s ease;
}
&:last-child {
margin-bottom: 0 !important;
}
&:hover {
background: rgba(30, 45, 70, 0.9);
border-left-color: rgba(150, 200, 255, 0.9);
box-shadow:
0 0 20px rgba(100, 150, 200, 0.2),
inset 0 0 30px rgba(100, 150, 200, 0.05);
cursor: pointer;
&::before {
opacity: 1;
}
}
.neu-menu-item-description {
color: rgba(140, 160, 180, 0.85);
font-size: 0.8rem;
line-height: 1.3;
margin-top: 0.35rem;
}
.neu-menu-item-icon {
display: none;
}
.neu-menu-item-title {
color: rgba(200, 220, 255, 1);
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
}
}
.center-label {
font-size: 0.65rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
/* Menu Items */
.radial-item {
position: absolute;
width: var(--item-size);
height: var(--item-size);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow);
text-align: center;
&:hover {
background: var(--bg-surface-hover);
border-color: var(--primary);
transform: scale(1.15);
box-shadow: var(--shadow-lg);
z-index: 5;
.radial-item-title {
color: var(--primary-hover);
}
}
&:active {
transform: scale(0.95);
}
}
.radial-item-icon {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.radial-item-title {
font-size: 0.6rem;
font-weight: 600;
color: var(--text-main);
line-height: 1.2;
transition: color 0.2s ease;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Tooltip */
.radial-tooltip {
position: fixed;
background: var(--primary-hover);
color: var(--text-inverse);
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 100;
box-shadow: var(--shadow-lg);
&.visible {
opacity: 1;
}
.tooltip-title {
font-weight: 600;
}
.tooltip-description {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.7);
margin-top: 0.15rem;
}
}
/* Empty state */
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
background: var(--bg-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
p {
font-size: 0.9rem;
}
}

View File

@ -1,186 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--secondary-color: #1e293b;
--background-color: rgba(15, 23, 42, 0.85);
--card-background: rgba(30, 41, 59, 0.95);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: #334155;
--success-color: #16a34a;
--success-hover: #15803d;
--button-hover: #4f46e5;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
line-height: 1.6;
background-color: transparent;
color: var(--text-primary);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.menu-container {
background-color: var(--background-color);
border-radius: 16px;
width: 90%;
max-width: 800px;
backdrop-filter: blur(10px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
.menu-header {
background-color: var(--secondary-color);
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
h1 {
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
margin: 0;
}
}
.menu-content {
padding: 1.5rem;
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
}
}
.menu-item {
background-color: var(--card-background);
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: fadeIn 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color);
}
.menu-item-icon {
font-size: 2rem;
margin-bottom: 1rem;
background: linear-gradient(
135deg,
var(--primary-color),
var(--button-hover)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
svg {
width: 24px;
height: 24px;
stroke: currentColor;
fill: none;
}
}
.menu-item-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.menu-item-description {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
}
.loading-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(248, 250, 252, 0.1);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--text-secondary);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.menu-container {
width: 95%;
margin: 1rem;
.menu-content {
.menu-grid {
grid-template-columns: 1fr;
}
}
.menu-header {
h1 {
font-size: 1.25rem;
}
}
}
}

View File

@ -1,4 +1,3 @@
forge_client_bank
===================
# forge_client_bank
Description for this addon

View File

@ -1,3 +1,5 @@
PREP(handleUIEvents);
PREP(initBankClass);
PREP(initClass);
PREP(initSessionService);
PREP(initUIBridge);
PREP(openUI);

View File

@ -1,6 +1,8 @@
#include "script_component.hpp"
if (isNil QGVAR(BankClass)) then { [] call FUNC(initBankClass); };
if (isNil QGVAR(BankClass)) then { call FUNC(initClass); };
if (isNil QGVAR(BankSessionService)) then { call FUNC(initSessionService); };
if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); };
[QGVAR(initBank), {
GVAR(BankClass) call ["init", []];
@ -10,12 +12,18 @@ if (isNil QGVAR(BankClass)) then { [] call FUNC(initBankClass); };
params [["_data", createHashMap, [createHashMap]]];
GVAR(BankClass) call ["sync", [_data, true]];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
}] call CFUNC(addEventHandler);
[QGVAR(responseSyncBank), {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
GVAR(BankClass) call ["sync", [_data, _jip]];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
}] call CFUNC(addEventHandler);
[{

View File

@ -8,6 +8,7 @@ class CfgPatches {
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_client_common",
"forge_client_main"
};
units[] = {};

View File

@ -1,19 +1,25 @@
#include "..\script_component.hpp"
/*
* File: fnc_handleUIEvents.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2026-02-17
* Public: No
*
* Description:
* Handles the UI events.
*
* Arguments:
* None
* 0: [CONTROL] - The control that triggered the event
* 1: [BOOL] - Whether the event is from a confirm dialog
* 2: [STRING] - The message containing the event data
*
* Return Value:
* None
* UI events handled [BOOL]
*
* Example:
* [] call forge_client_bank_fnc_handleUIEvents;
*
* Public: No
* call forge_client_bank_fnc_handleUIEvents;
*/
params ["_control", "_isConfirmDialog", "_message"];
@ -21,90 +27,49 @@ params ["_control", "_isConfirmDialog", "_message"];
private _alert = fromJSON _message;
private _event = _alert get "event";
private _data = _alert get "data";
private _display = displayChild findDisplay 46;
private _uid = GVAR(BankClass) get "uid";
private _account = GVAR(BankClass) get "account";
private _cash = _account get "cash";
private _bank = _account get "bank";
private _pin = _account get "pin";
diag_log format ["[FORGE:Client:Bank] Handling UI event: %1 with data: %2", _event, _data];
switch (_event) do {
// ========================================================================
// DATA REQUESTS
// ========================================================================
case "bank::sync": {
private _org = 0; // TODO: Get org balance
private _players = SREG(bank,NameRegistry);
private _accountData = createHashMapFromArray [
["uid", _uid],
["cash", _cash],
["bank", _bank],
["org", _org],
["pin", _pin],
["players", _players]
];
_control ctrlWebBrowserAction ["ExecJS", format ["syncDataFromArma(%1)", toJSON _accountData]];
};
// ========================================================================
// BANK OPERATIONS
// ========================================================================
case "bank::deposit": {
private _amount = _data get "amount";
if (_amount > _cash) exitWith { hint "Insufficient cash!"; };
[SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent);
};
case "bank::withdraw": {
private _amount = _data get "amount";
if (_amount > _bank) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent);
};
case "bank::transfer": {
private _amount = _data get "amount";
private _from = _data get "from";
private _target = _data get "target";
// Prevent self-transfers
if (_target isEqualTo _uid) exitWith {
hint "Cannot transfer to yourself!";
diag_log "[FORGE:Client:Bank] Attempted self-transfer blocked";
case "bank::close": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleClose", []];
};
private _fromAmount = _account get _from;
if (_amount > _fromAmount) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestTransfer), [_uid, _target, _from, _amount]] call CFUNC(serverEvent);
closeDialog 1;
};
case "bank::close": {
_display closeDisplay 1;
case "bank::ready": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleReady", [_control, _data]];
};
};
// ========================================================================
// ATM OPERATIONS
// ========================================================================
case "atm::withdraw": {
private _amount = _data get "amount";
if (_amount > _bank) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent);
case "bank::refresh": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
};
case "atm::deposit": {
private _amount = _data get "amount";
if (_amount > _cash) exitWith { hint "Insufficient cash!"; };
[SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent);
case "bank::deposit::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleDepositRequest", [_data]];
};
};
case "atm::close": {
_display closeDisplay 1;
case "bank::withdraw::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleWithdrawRequest", [_data]];
};
};
case "bank::transfer::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleTransferRequest", [_data]];
};
};
case "bank::depositEarnings::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]];
};
};
default {
diag_log format ["[FORGE:Client:Bank] Unhandled UI event: %1", _event];
hint format ["Unhandled bank UI event: %1", _event];
};
};

View File

@ -1,84 +0,0 @@
#include "..\script_component.hpp"
/*
* Author: IDSolutions
* Initializes the bank class.
*
* Arguments:
* None
*
* Return Value:
* None
*
* Examples:
* [] call forge_client_bank_fnc_initBankClass
*
* Public: Yes
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankClass) = createHashMapObject [[
["#type", "IBankClass"],
["#create", {
_self set ["uid", getPlayerUID player];
_self set ["account", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
private _account = createHashMap;
_account set ["uid", (getPlayerUID player)];
_account set ["name", (name player)];
_account set ["bank", 0];
_account set ["cash", 0];
_account set ["earnings", 0];
_account set ["pin", 1234];
_account set ["transactions", []];
_self set ["account", _account];
}],
["init", {
private _uid = _self get "uid";
private _account = _self get "account";
[SRPC(bank,requestInitBank), [_uid, _account]] call CFUNC(serverEvent);
systemChat format ["Bank loaded for %1", (name player)];
diag_log "[FORGE:Client:Bank] Bank Class Initialized!";
}],
["save", {
params [["_sync", false, [false]]];
private _uid = _self get "uid";
[SRPC(bank,requestSaveBank), [_uid, _sync]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
private _account = _self get "account";
private _isLoaded = _self get "isLoaded";
if (_data isEqualTo createHashMap) exitWith {
diag_log "[FORGE:Client:Bank] Empty data received for sync, skipping.";
};
{
_account set [_x, _y];
} forEach _data;
_self set ["account", _account];
if !(_isLoaded) then { _self set ["isLoaded", true]; };
diag_log "[FORGE:Client:Bank] Sync completed";
}],
["get", {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _account = _self get "account";
_account getOrDefault [_key, _default];
}]
]];
SETVAR(player,FORGE_BankClass,GVAR(BankClass));
GVAR(BankClass)

View File

@ -0,0 +1,62 @@
#include "..\script_component.hpp"
/*
* File: fnc_initClass.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank class for account sync and access helpers.
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankBaseClass) = compileFinal createHashMapFromArray [
["#type", "BankBaseClass"],
["#create", compileFinal {
_self set ["uid", getPlayerUID player];
_self set ["account", createHashMapFromArray [
["bank", 0],
["cash", 0],
["earnings", 0],
["pin", 1234],
["transactions", []]
]];
_self set ["isLoaded", false];
_self set ["lastSave", time];
}],
["getAccountState", compileFinal {
_self getOrDefault ["account", createHashMap]
}],
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _account = _self getOrDefault ["account", createHashMap];
_account getOrDefault [_key, _default]
}],
["init", compileFinal {
[SRPC(bank,requestInitBank), [getPlayerUID player]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["save", compileFinal {
[SRPC(bank,requestSaveBank), [getPlayerUID player]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
private _account = _self getOrDefault ["account", createHashMap];
{
_account set [_x, _y];
} forEach _data;
_self set ["account", _account];
if !(_self getOrDefault ["isLoaded", false]) then {
_self set ["isLoaded", true];
};
true
}]
];
GVAR(BankClass) = createHashMapObject [GVAR(BankBaseClass)];
GVAR(BankClass)

View File

@ -0,0 +1,80 @@
#include "..\script_component.hpp"
/*
* File: fnc_initSessionService.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank session service that shapes the browser payload.
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankSessionServiceBaseClass) = compileFinal createHashMapFromArray [
["#type", "BankSessionServiceBaseClass"],
["buildTransferTargets", compileFinal {
private _targets = [];
{
if (isNull _x || { _x isEqualTo player }) then {
continue;
};
private _uid = getPlayerUID _x;
private _name = name _x;
if (_uid isEqualTo "" || { _name isEqualTo "" }) then {
continue;
};
_targets pushBack (createHashMapFromArray [
["name", _name],
["uid", _uid]
]);
} forEach allPlayers;
private _targetPairs = _targets apply {
[toLowerANSI (_x getOrDefault ["name", ""]), _x]
};
_targetPairs sort true;
_targetPairs apply {
_x param [1, createHashMap]
}
}],
["buildPayload", compileFinal {
params [["_mode", "bank", [""]]];
private _account = if (isNil QGVAR(BankClass)) then {
createHashMap
} else {
GVAR(BankClass) call ["getAccountState", []]
};
private _orgFunds = 0;
private _orgName = "";
if !(isNil QEGVAR(org,OrgClass)) then {
_orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]];
_orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]];
};
createHashMapFromArray [
["session", createHashMapFromArray [
["mode", ["bank", "atm"] select (toLowerANSI _mode isEqualTo "atm")],
["orgFunds", _orgFunds],
["orgName", _orgName],
["playerName", name player],
["transferTargets", _self call ["buildTransferTargets", []]],
["uid", getPlayerUID player]
]],
["account", createHashMapFromArray [
["bank", _account getOrDefault ["bank", 0]],
["cash", _account getOrDefault ["cash", 0]],
["earnings", _account getOrDefault ["earnings", 0]],
["pin", str (_account getOrDefault ["pin", 1234])],
["transactions", _account getOrDefault ["transactions", []]]
]]
]
}]
];
GVAR(BankSessionService) = createHashMapObject [GVAR(BankSessionServiceBaseClass)];
GVAR(BankSessionService)

View File

@ -0,0 +1,134 @@
#include "..\script_component.hpp"
/*
* File: fnc_initUIBridge.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank web UI bridge.
*/
#pragma hemtt ignore_variables ["_self"]
private _webUIDeclarations = call EFUNC(common,initWebUIBridge);
private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration";
GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [
["#base", _webUIBridgeDeclaration],
["#type", "BankUIBridgeBaseClass"],
["#create", compileFinal {
_self set ["mode", "bank"];
}],
["buildPayload", compileFinal {
GVAR(BankSessionService) call ["buildPayload", [_self call ["getMode", []]]]
}],
["getActiveBrowserControl", compileFinal {
private _display = uiNamespace getVariable ["RscBank", displayNull];
if (isNull _display) exitWith {
_self call ["setActiveBrowserControl", [controlNull]];
controlNull
};
private _control = _display displayCtrl 1002;
_self call ["setActiveBrowserControl", [_control]];
_control
}],
["getMode", compileFinal {
_self getOrDefault ["mode", "bank"]
}],
["handleDepositEarningsRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "No earnings are available to deposit."]];
};
[SRPC(bank,requestDepositEarnings), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["handleDepositRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid deposit amount."]];
};
[SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["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", ["bank::hydrate", _self call ["buildPayload", []], _control]];
}],
["handleTransferRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
private _target = _data getOrDefault ["target", ""];
private _from = toLowerANSI (_data getOrDefault ["from", "bank"]);
if (_target isEqualTo "") exitWith {
_self call ["sendNotice", ["error", "Select a transfer recipient."]];
};
if (_target isEqualTo getPlayerUID player) exitWith {
_self call ["sendNotice", ["error", "You cannot transfer funds to yourself."]];
};
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid transfer amount."]];
};
[SRPC(bank,requestTransfer), [getPlayerUID player, _target, _from, _amount]] call CFUNC(serverEvent);
true
}],
["handleWithdrawRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid withdrawal amount."]];
};
[SRPC(bank,requestWithdraw), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["refreshSession", compileFinal {
private _control = _self call ["getActiveBrowserControl", []];
if (isNull _control) exitWith { false };
_self call ["sendEvent", ["bank::sync", _self call ["buildPayload", []], _control]]
}],
["sendNotice", compileFinal {
params [["_type", "error", [""]], ["_message", "", [""]], ["_control", controlNull, [controlNull]]];
if (_message isEqualTo "") exitWith { false };
_self call ["sendEvent", ["bank::notice", createHashMapFromArray [
["message", _message],
["type", _type]
], _control]]
}],
["setMode", compileFinal {
params [["_mode", "bank", [""]]];
private _finalMode = toLowerANSI _mode;
if !(_finalMode in ["bank", "atm"]) then {
_finalMode = "bank";
};
_self set ["mode", _finalMode];
_finalMode
}]
];
GVAR(BankUIBridge) = createHashMapObject [GVAR(BankUIBridgeBaseClass)];
GVAR(BankUIBridge)

View File

@ -1,25 +1,29 @@
#include "..\script_component.hpp"
/*
* File: fnc_openUI.sqf
* Author: IDSolutions
* Date: 2026-01-28
* Last Update: 2026-01-30
* Public: No
*
* Description:
* Opens the player bank interaction interface.
*
* Arguments:
* None
* 0: [BOOL] - Whether to open the ATM interface
*
* Return Value:
* None
* UI opened [BOOL]
*
* Example:
* [] call forge_client_bank_fnc_openUI;
*
* Public: No
* [true] call forge_client_bank_fnc_openUI;
*/
params [["_isATM", false, [false]]];
private _display = (findDisplay 46) createDisplay "RscBank";
private _ctrl = (_display displayCtrl 1002);
private _display = createDialog ["RscBank", true];
private _ctrl = _display displayCtrl 1002;
_ctrl ctrlAddEventHandler ["JSDialog", {
params ["_control", "_isConfirmDialog", "_message"];
@ -27,11 +31,11 @@ _ctrl ctrlAddEventHandler ["JSDialog", {
[_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
}];
if (_isATM) then {
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\atm.html)];
} else {
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bank.html)];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["setMode", [["bank", "atm"] select _isATM]];
GVAR(BankUIBridge) call ["setActiveBrowserControl", [_ctrl]];
};
// _ctrl ctrlWebBrowserAction ["OpenDevConsole"];
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)];
true;

View File

@ -1,585 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
width: 100vw;
background: rgba(0, 0, 0, 0.5);
font-family: Arial, sans-serif;
color: rgba(200, 220, 240, 0.95);
overflow: hidden;
}
.atm-container {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 5%;
perspective: 1200px;
}
.atm-screen {
width: 480px;
height: 640px;
background: rgba(15, 20, 30, 0.95);
border: 2px solid rgba(100, 150, 200, 0.5);
border-radius: 8px;
transform: rotateY(-10deg) translateZ(0);
transform-style: preserve-3d;
box-shadow:
-8px 0 20px rgba(100, 150, 200, 0.25),
0 8px 32px rgba(0, 0, 0, 0.8);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
margin-right: 25%;
}
/* Header */
.atm-header {
padding: 1.25rem 1.5rem;
background: rgba(20, 30, 45, 0.9);
border-bottom: 2px solid rgba(100, 150, 200, 0.3);
display: flex;
align-items: center;
gap: 1rem;
}
.atm-logo {
font-size: 2rem;
}
.atm-title {
font-size: 1rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: rgba(100, 150, 200, 1);
}
/* Content */
.atm-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
overflow-x: hidden;
}
.atm-content::-webkit-scrollbar {
width: 6px;
}
.atm-content::-webkit-scrollbar-track {
background: rgba(15, 20, 30, 0.5);
}
.atm-content::-webkit-scrollbar-thumb {
background: rgba(100, 150, 200, 0.3);
border-radius: 3px;
}
.atm-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: space-between;
}
.atm-view h3 {
font-size: 1.125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(200, 220, 255, 1);
text-align: center;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(100, 150, 200, 0.2);
}
/* Welcome Screen */
.welcome-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem 1rem;
flex: 1;
}
.welcome-icon {
font-size: 4rem;
opacity: 0.6;
}
.welcome-message h2 {
font-size: 1.5rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
}
.welcome-message p {
font-size: 0.875rem;
color: rgba(140, 160, 180, 0.85);
}
/* PIN Entry */
.pin-entry {
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
justify-content: center;
}
.pin-entry h3 {
margin: 0;
padding: 0;
border: none;
}
.pin-display {
display: flex;
justify-content: center;
gap: 1rem;
padding: 1.5rem;
}
.pin-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(100, 150, 200, 0.2);
border: 2px solid rgba(100, 150, 200, 0.4);
transition: all 0.2s ease;
}
.pin-dot.filled {
background: rgba(100, 150, 200, 0.8);
border-color: rgba(150, 200, 255, 0.8);
box-shadow: 0 0 10px rgba(100, 150, 200, 0.5);
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.key-btn {
padding: 1rem;
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: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.key-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);
}
.key-btn:active {
transform: scale(0.95);
}
.key-clear {
background: rgba(200, 150, 100, 0.2);
border-color: rgba(200, 150, 100, 0.4);
}
.key-clear:hover {
background: rgba(200, 150, 100, 0.3);
border-color: rgba(255, 200, 150, 0.6);
}
.key-enter {
background: rgba(100, 150, 200, 0.2);
border-color: rgba(100, 150, 200, 0.5);
}
.key-enter:hover {
background: rgba(100, 150, 200, 0.3);
border-color: rgba(150, 200, 255, 0.7);
}
/* Account Summary */
.account-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
flex-shrink: 0;
}
.summary-item {
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;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.summary-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.85);
}
.summary-value {
font-size: 1.25rem;
font-weight: 600;
color: rgba(100, 200, 150, 1);
}
/* Menu Options */
.menu-options {
display: grid;
grid-template-rows: 1fr;
gap: 1rem;
}
.menu-btn {
padding: 1rem;
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;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.menu-btn:hover {
background: rgba(30, 45, 70, 0.8);
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);
}
.menu-icon {
font-size: 2rem;
}
.menu-text {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(200, 220, 240, 0.95);
}
/* Quick Amounts */
.withdraw-display,
.deposit-display,
.transfer-display {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.quick-amounts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.amount-btn {
padding: 1rem;
background: rgba(20, 30, 45, 0.7);
border: 1px solid rgba(100, 150, 200, 0.4);
border-radius: 4px;
color: rgba(100, 200, 150, 1);
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.amount-btn:hover {
background: rgba(30, 45, 70, 0.9);
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);
}
/* Custom Amount */
.custom-amount {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.custom-amount label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.85);
}
/* Form Fields */
.transfer-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.85);
}
.amount-input,
.text-input {
padding: 0.875rem 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: 1rem;
transition: all 0.15s ease;
}
.amount-input:focus,
.text-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);
}
.amount-input::placeholder,
.text-input::placeholder {
color: rgba(100, 120, 140, 0.6);
}
/* Balance Display */
.balance-display {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.balance-item {
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;
display: flex;
justify-content: space-between;
align-items: center;
}
.balance-label {
font-size: 0.875rem;
color: rgba(160, 180, 200, 0.85);
}
.balance-amount {
font-size: 1.25rem;
font-weight: 600;
color: rgba(100, 200, 150, 1);
}
.balance-total {
border-left-color: rgba(100, 200, 150, 0.6);
background: rgba(30, 45, 70, 0.7);
}
.balance-total .balance-label {
font-size: 1rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
}
.balance-total .balance-amount {
font-size: 1.5rem;
}
/* Deposit Info */
.atm-btn-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.deposit-info {
padding: 1rem;
background: rgba(20, 30, 45, 0.5);
border: 1px solid rgba(100, 150, 200, 0.2);
border-radius: 4px;
text-align: center;
}
.deposit-info p {
font-size: 0.875rem;
color: rgba(160, 180, 200, 0.85);
}
.deposit-info span {
font-weight: 600;
color: rgba(100, 200, 150, 1);
}
/* Transaction Result */
.transaction-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem 1rem;
flex: 1;
}
.result-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: bold;
}
.transaction-result.success .result-icon {
background: rgba(100, 200, 150, 0.2);
border: 3px solid rgba(100, 200, 150, 0.6);
color: rgba(150, 255, 200, 1);
box-shadow: 0 0 20px rgba(100, 200, 150, 0.3);
}
.transaction-result.error .result-icon {
background: rgba(200, 100, 100, 0.2);
border: 3px solid rgba(200, 100, 100, 0.6);
color: rgba(255, 150, 150, 1);
box-shadow: 0 0 20px rgba(200, 100, 100, 0.3);
}
.transaction-result h3 {
margin: 0;
padding: 0;
border: none;
}
.transaction-result p {
font-size: 0.875rem;
color: rgba(160, 180, 200, 0.85);
text-align: center;
}
/* Buttons */
.atm-btn {
padding: 1rem;
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;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.atm-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);
}
.atm-btn-primary {
background: rgba(100, 150, 200, 0.2);
border-color: rgba(100, 150, 200, 0.5);
}
.atm-btn-primary:hover {
background: rgba(100, 150, 200, 0.3);
border-color: rgba(150, 200, 255, 0.7);
}
.atm-btn-secondary {
background: rgba(200, 150, 100, 0.2);
border-color: rgba(200, 150, 100, 0.4);
}
.atm-btn-secondary:hover {
background: rgba(200, 150, 100, 0.3);
border-color: rgba(255, 200, 150, 0.6);
}
.atm-btn-full {
background: rgba(100, 200, 150, 0.2);
border-color: rgba(100, 200, 150, 0.4);
}
.atm-btn-full:hover {
background: rgba(100, 200, 150, 0.3);
border-color: rgba(150, 255, 200, 0.6);
}
/* Footer */
.atm-footer {
padding: 1rem 1.5rem;
background: rgba(20, 30, 45, 0.9);
border-top: 2px solid rgba(100, 150, 200, 0.3);
text-align: center;
}
.footer-text {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(100, 150, 200, 0.7);
}
/* Responsive */
@media (max-width: 768px) {
.atm-container {
justify-content: center;
padding: 1rem;
}
.atm-screen {
transform: none;
width: 100%;
max-width: 450px;
}
}

View File

@ -1,248 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATM</title>
<!-- <script src="store.js"></script> -->
<!-- <link rel="stylesheet" href="atm.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
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\atm.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\atm.js",
),
]).then(([css, storeJs, atmJs]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const store = document.createElement("script");
store.text = storeJs;
document.head.appendChild(store);
const atm = document.createElement("script");
atm.text = atmJs;
document.head.appendChild(atm);
});
</script>
</head>
<body>
<div class="atm-container">
<div class="atm-screen">
<!-- Header -->
<div class="atm-header">
<div class="atm-logo">💳</div>
<div class="atm-title">AUTOMATED TELLER</div>
</div>
<!-- Main Content Area -->
<div class="atm-content" id="atmContent">
<!-- Welcome Screen -->
<div class="atm-view" id="welcomeView">
<div class="welcome-message">
<div class="welcome-icon">👤</div>
<h2>Welcome</h2>
<p>Insert your card to begin</p>
</div>
<button class="atm-btn atm-btn-primary" onclick="showView('pinView')">
Insert Card
</button>
</div>
<!-- PIN Entry Screen -->
<div class="atm-view" id="pinView" style="display: none;">
<div class="pin-entry">
<h3>Enter PIN</h3>
<div class="pin-display">
<span class="pin-dot"></span>
<span class="pin-dot"></span>
<span class="pin-dot"></span>
<span class="pin-dot"></span>
</div>
<div class="keypad" id="keypad">
<!-- Keypad buttons will be generated by JavaScript -->
</div>
</div>
</div>
<!-- Main Menu Screen -->
<div class="atm-view" id="menuView" style="display: none;">
<div class="account-summary">
<div class="summary-item">
<span class="summary-label">Cash</span>
<span class="summary-value" id="cashBalance">$2,500</span>
</div>
<div class="summary-item">
<span class="summary-label">Bank</span>
<span class="summary-value" id="bankBalance">$45,750</span>
</div>
</div>
<div class="menu-options">
<button class="menu-btn" onclick="showView('withdrawView')">
<span class="menu-text">Withdraw</span>
</button>
<!-- <button class="menu-btn" onclick="showView('depositView')"> -->
<!-- <span class="menu-text">Deposit</span> -->
<!-- </button> -->
<!-- <button class="menu-btn" onclick="showView('transferView')"> -->
<!-- <span class="menu-text">Transfer</span> -->
<!-- </button> -->
<button class="menu-btn" onclick="showView('balanceView')">
<span class="menu-text">Balance</span>
</button>
</div>
<button class="atm-btn atm-btn-secondary" onclick="exitATM()">
Exit
</button>
</div>
<!-- Withdraw Screen -->
<div class="atm-view" id="withdrawView" style="display: none;">
<h3>Withdraw Cash</h3>
<div class="withdraw-display">
<div class="quick-amounts">
<button class="amount-btn" onclick="withdrawAmount(100)">$100</button>
<button class="amount-btn" onclick="withdrawAmount(500)">$500</button>
<button class="amount-btn" onclick="withdrawAmount(1000)">$1,000</button>
<button class="amount-btn" onclick="withdrawAmount(2000)">$2,000</button>
</div>
<div class="custom-amount">
<label>Custom Amount</label>
<input type="number" class="amount-input" id="withdrawInput" placeholder="0.00" min="0"
step="1">
</div>
</div>
<div class="atm-btn-group">
<button class="atm-btn atm-btn-primary" onclick="withdrawCustom()">
Withdraw
</button>
<button class="atm-btn atm-btn-secondary" onclick="showView('menuView')">
Back
</button>
</div>
</div>
<!-- Deposit Screen -->
<!-- <div class="atm-view" id="depositView" style="display: none;">
<h3>Deposit Cash</h3>
<div class="deposit-display">
<div class="deposit-info">
<p>Available Cash: <span id="availableCash">$2,500</span></p>
</div>
<div class="custom-amount">
<label>Amount to Deposit</label>
<input type="number" class="amount-input" id="depositInput" placeholder="0.00" min="0"
step="1">
</div>
</div>
<div class="atm-btn-group">
<button class="atm-btn atm-btn-primary" onclick="depositAmount()">
Deposit
</button>
<button class="atm-btn atm-btn-full" onclick="depositAll()">
Deposit All Cash
</button>
<button class="atm-btn atm-btn-secondary" onclick="showView('menuView')">
Back
</button>
</div>
</div> -->
<!-- Transfer Screen -->
<!-- <div class="atm-view" id="transferView" style="display: none;">
<h3>Transfer Funds</h3>
<div class="transfer-display">
<div class="transfer-form">
<div class="form-field">
<label>To Player ID</label>
<input type="text" class="text-input" id="transferPlayerId"
placeholder="Enter player ID">
</div>
<div class="form-field">
<label>Amount</label>
<input type="number" class="amount-input" id="transferAmount" placeholder="0.00" min="0"
step="1">
</div>
</div>
</div>
<div class="atm-btn-group">
<button class="atm-btn atm-btn-primary" onclick="transferFunds()">
Transfer
</button>
<button class="atm-btn atm-btn-secondary" onclick="showView('menuView')">
Back
</button>
</div>
</div> -->
<!-- Balance Screen -->
<div class="atm-view" id="balanceView" style="display: none;">
<h3>Account Balance</h3>
<div class="balance-display">
<div class="balance-item">
<span class="balance-label">Cash on Hand</span>
<span class="balance-amount" id="cashBalanceDetail">$2,500</span>
</div>
<div class="balance-item">
<span class="balance-label">Bank Account</span>
<span class="balance-amount" id="bankBalanceDetail">$45,750</span>
</div>
<div class="balance-item balance-total">
<span class="balance-label">Total Assets</span>
<span class="balance-amount" id="totalBalance">$48,250</span>
</div>
</div>
<button class="atm-btn atm-btn-secondary" onclick="showView('menuView')">
Back
</button>
</div>
<!-- Transaction Success Screen -->
<div class="atm-view" id="successView" style="display: none;">
<div class="transaction-result success">
<div class="result-icon"></div>
<h3>Transaction Complete</h3>
<p id="successMessage">Your transaction was successful</p>
</div>
<button class="atm-btn atm-btn-primary" onclick="showView('menuView')">
Continue
</button>
</div>
<!-- Transaction Error Screen -->
<div class="atm-view" id="errorView" style="display: none;">
<div class="transaction-result error">
<div class="result-icon"></div>
<h3>Transaction Failed</h3>
<p id="errorMessage">An error occurred</p>
</div>
<button class="atm-btn atm-btn-secondary" onclick="goBackFromError()">
Back
</button>
</div>
</div>
<!-- Footer -->
<div class="atm-footer">
<div class="footer-text">Secure Banking • 24/7 Access</div>
</div>
</div>
</div>
<!-- <script src="atm.js"></script> -->
</body>
</html>

View File

@ -1,380 +0,0 @@
/**
* ATM Interface
* Handles banking transactions with PIN authentication
*/
// ============================================================================
// STATE
// ============================================================================
let enteredPin = '';
let currentView = 'welcomeView';
let previousView = 'welcomeView';
// ============================================================================
// VIEW MANAGEMENT
// ============================================================================
function showView(viewId) {
// Hide all views
document.querySelectorAll('.atm-view').forEach(view => {
view.style.display = 'none';
});
// Show selected view
const view = document.getElementById(viewId);
if (view) {
view.style.display = 'flex';
previousView = currentView;
currentView = viewId;
// Update balance displays when showing certain views
if (viewId === 'menuView' || viewId === 'balanceView' || viewId === 'depositView') {
updateBalances();
}
}
}
// ============================================================================
// PIN AUTHENTICATION
// ============================================================================
function generateKeypad() {
const keypad = document.getElementById('keypad');
if (!keypad) return;
// Define keypad layout
const keys = [
{ value: '1', label: '1', type: 'number' },
{ value: '2', label: '2', type: 'number' },
{ value: '3', label: '3', type: 'number' },
{ value: '4', label: '4', type: 'number' },
{ value: '5', label: '5', type: 'number' },
{ value: '6', label: '6', type: 'number' },
{ value: '7', label: '7', type: 'number' },
{ value: '8', label: '8', type: 'number' },
{ value: '9', label: '9', type: 'number' },
{ value: 'clear', label: 'Clear', type: 'action', class: 'key-clear' },
{ value: '0', label: '0', type: 'number' },
{ value: 'enter', label: 'Enter', type: 'action', class: 'key-enter' }
];
// Clear existing keypad
keypad.innerHTML = '';
// Generate buttons
keys.forEach(key => {
const button = document.createElement('button');
button.className = `key-btn${key.class ? ' ' + key.class : ''}`;
button.textContent = key.label;
// Add click handler
if (key.type === 'number') {
button.onclick = () => enterPin(key.value);
} else if (key.value === 'clear') {
button.onclick = () => clearPin();
} else if (key.value === 'enter') {
button.onclick = () => submitPin();
}
keypad.appendChild(button);
});
}
function enterPin(digit) {
if (enteredPin.length < 4) {
enteredPin += digit;
updatePinDisplay();
}
}
function clearPin() {
enteredPin = '';
updatePinDisplay();
}
function updatePinDisplay() {
const dots = document.querySelectorAll('.pin-dot');
dots.forEach((dot, index) => {
if (index < enteredPin.length) {
dot.classList.add('filled');
} else {
dot.classList.remove('filled');
}
});
}
function submitPin() {
if (enteredPin.length !== 4) {
showError('Please enter a 4-digit PIN');
return;
}
// In a real implementation, this would validate with the server
const currentState = store.getState();
if (enteredPin === currentState.pin) {
enteredPin = '';
updatePinDisplay();
showView('menuView');
} else {
showError('Incorrect PIN');
clearPin();
}
}
// ============================================================================
// BALANCE MANAGEMENT
// ============================================================================
function updateBalances() {
const currentState = store.getState();
// Update all balance displays
const cashElements = ['cashBalance', 'cashBalanceDetail', 'availableCash'];
const bankElements = ['bankBalance', 'bankBalanceDetail'];
cashElements.forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = `$${currentState.accounts.cash.toLocaleString()}`;
});
bankElements.forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = `$${currentState.accounts.bank.toLocaleString()}`;
});
const totalEl = document.getElementById('totalBalance');
if (totalEl) {
const total = currentState.accounts.cash + currentState.accounts.bank;
totalEl.textContent = `$${total.toLocaleString()}`;
}
}
// ============================================================================
// WITHDRAW OPERATIONS
// ============================================================================
function withdrawAmount(amount) {
const currentState = store.getState();
if (amount > currentState.accounts.bank) {
showError('Insufficient funds');
return;
}
store.dispatch(withdraw(amount));
sendEvent('atm::withdraw', { amount: amount });
showSuccess(`Withdrew $${amount.toLocaleString()}`);
}
function withdrawCustom() {
const input = document.getElementById('withdrawInput');
const amount = parseFloat(input.value);
if (!amount || amount <= 0) {
showError('Please enter a valid amount');
return;
}
const currentState = store.getState();
if (amount > currentState.accounts.bank) {
showError('Insufficient funds');
return;
}
store.dispatch(withdraw(amount));
sendEvent('atm::withdraw', { amount: amount });
input.value = '';
showSuccess(`Withdrew $${amount.toLocaleString()}`);
}
// ============================================================================
// DEPOSIT OPERATIONS
// ============================================================================
/**
* Deposits specified amount into bank account
* @deprecated Use store actions instead
*/
function depositAmount() {
const input = document.getElementById('depositInput');
const amount = parseFloat(input.value);
if (!amount || amount <= 0) {
showError('Please enter a valid amount');
return;
}
const currentState = store.getState();
if (amount > currentState.accounts.cash) {
showError('Insufficient cash');
return;
}
store.dispatch(deposit(amount));
sendEvent('atm::deposit', { amount: amount });
input.value = '';
showSuccess(`Deposited $${amount.toLocaleString()}`);
}
/**
* Deposits all available cash into bank account
* @deprecated Use store actions instead
*/
function depositAll() {
const currentState = store.getState();
if (currentState.accounts.cash <= 0) {
showError('No cash to deposit');
return;
}
const amount = currentState.accounts.cash;
store.dispatch(deposit(amount));
sendEvent('atm::deposit', { amount: amount });
showSuccess(`Deposited $${amount.toLocaleString()}`);
}
// ============================================================================
// TRANSFER OPERATIONS
// ============================================================================
/**
* Transfers specified amount from bank account to player account
* @deprecated Use store actions instead
*/
function transferFunds() {
const playerIdInput = document.getElementById('transferPlayerId');
const amountInput = document.getElementById('transferAmount');
const playerId = playerIdInput.value.trim();
const amount = parseFloat(amountInput.value);
if (!playerId) {
showError('Please enter a player ID');
return;
}
if (!amount || amount <= 0) {
showError('Please enter a valid amount');
return;
}
const currentState = store.getState();
if (amount > currentState.accounts.bank) {
showError('Insufficient funds');
return;
}
store.dispatch(transfer('bank', amount, 'player'));
sendEvent('atm::transfer', {
playerId: playerId,
amount: amount
});
playerIdInput.value = '';
amountInput.value = '';
showSuccess(`Transferred $${amount.toLocaleString()} to Player ${playerId}`);
}
// ============================================================================
// RESULT SCREENS
// ============================================================================
function showSuccess(message) {
document.getElementById('successMessage').textContent = message;
showView('successView');
updateBalances();
}
function showError(message) {
document.getElementById('errorMessage').textContent = message;
showView('errorView');
}
function goBackFromError() {
// If error happened during PIN entry, go back to PIN view
// Otherwise go back to menu view
if (previousView === 'pinView') {
showView('pinView');
} else {
showView('menuView');
}
}
// ============================================================================
// ATM CONTROL
// ============================================================================
function exitATM() {
enteredPin = '';
updatePinDisplay();
sendEvent('atm::close', {});
showView('welcomeView');
}
// ============================================================================
// ARMA 3 INTEGRATION
// ============================================================================
/**
* Sends an event to Arma 3
* @param {string} event - Event name
* @param {Object} data - Event data
*/
function sendEvent(event, data) {
if (typeof A3API !== 'undefined') {
A3API.SendAlert(JSON.stringify({
event: event,
data: data
}));
} else {
console.log('Event:', event, 'Data:', data);
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
function initATM() {
// Subscribe to store updates
if (typeof store !== 'undefined') {
store.subscribe(() => {
updateBalances();
});
}
// Generate keypad
generateKeypad();
// Show welcome screen
showView('welcomeView');
// Update initial balances
updateBalances();
console.log('[ATM] Interface initialized');
}
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initATM);
} else {
initATM();
}
// ============================================================================
// GLOBAL EXPORTS
// ============================================================================
window.showView = showView;
window.generateKeypad = generateKeypad;
window.enterPin = enterPin;
window.clearPin = clearPin;
window.submitPin = submitPin;
window.withdrawAmount = withdrawAmount;
window.withdrawCustom = withdrawCustom;
window.depositAmount = depositAmount;
window.depositAll = depositAll;
window.transferFunds = transferFunds;
window.goBackFromError = goBackFromError;
window.exitATM = exitATM;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,449 +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;
}
.bank-container {
height: 100vh;
width: 100vw;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bank-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);
}
.bank-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;
}
.bank-info {
flex: 1;
}
.bank-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;
}
.bank-subtitle {
font-size: 0.875rem;
color: rgba(140, 160, 180, 0.8);
letter-spacing: 0.5px;
}
.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;
&: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);
}
&-primary {
background: rgba(100, 150, 200, 0.2);
border-color: rgba(100, 150, 200, 0.5);
width: 100%;
margin-top: 0.5rem;
&:hover {
background: rgba(100, 150, 200, 0.3);
border-color: rgba(150, 200, 255, 0.7);
}
}
}
.close-btn {
border-color: rgba(200, 100, 100, 0.4);
&: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);
}
}
.bank-content {
flex: 1;
display: grid;
grid-template-columns: 300px 1fr 350px;
gap: 1.5rem;
overflow: hidden;
}
.bank-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);
&-main {
grid-column: 2;
}
}
.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;
&::-webkit-scrollbar {
width: 8px;
&-track {
background: rgba(15, 20, 30, 0.5);
border-radius: 4px;
}
&-thumb {
background: rgba(100, 150, 200, 0.3);
border-radius: 4px;
&:hover {
background: rgba(100, 150, 200, 0.5);
}
}
}
}
.account-card {
padding: 1.25rem;
margin-bottom: 1rem;
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;
transition: all 0.15s ease;
&:last-child {
margin-bottom: 0;
}
.account-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
.account-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
.account-name {
font-size: 1rem;
font-weight: 600;
color: rgba(200, 220, 255, 1);
}
.account-type {
font-size: 0.75rem;
color: rgba(140, 160, 180, 0.8);
}
}
}
.account-balance {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid rgba(100, 150, 200, 0.2);
.balance-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.8);
}
.balance-amount {
font-size: 1.25rem;
font-weight: 600;
color: rgba(100, 200, 150, 1);
}
}
}
.action-section {
margin-bottom: 2rem;
&:last-child {
margin-bottom: 0;
}
.section-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;
}
.transfer-form {
display: flex;
flex-direction: column;
gap: 1rem;
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
.form-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(140, 160, 180, 0.9);
}
.form-select,
.form-input {
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;
&: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);
}
}
.form-select {
padding-right: 2.5rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2396C8FF' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 12px 8px;
cursor: pointer;
}
.form-input {
&::placeholder {
color: rgba(100, 120, 140, 0.6);
}
}
}
}
}
input[type=number] {
-moz-appearance: textfield;
appearance: textfield;
margin: 0;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
.quick-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
background: rgba(20, 30, 45, 0.6);
border: 1px solid rgba(100, 150, 200, 0.3);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(30, 45, 70, 0.8);
border-color: rgba(150, 200, 255, 0.5);
box-shadow:
0 0 15px rgba(100, 150, 200, 0.15),
inset 0 0 20px rgba(100, 150, 200, 0.05);
}
.quick-action-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: center;
color: rgba(180, 200, 220, 0.9);
}
}
}
.transaction-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
.transaction-item {
padding: 1rem;
background: rgba(20, 30, 45, 0.6);
border: 1px solid rgba(100, 150, 200, 0.2);
border-left: 3px solid rgba(100, 150, 200, 0.4);
border-radius: 4px;
transition: all 0.15s ease;
}
.transaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
.transaction-type {
padding: 0.25rem 0.625rem;
border-radius: 3px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
&.deposit {
background: rgba(100, 200, 150, 0.2);
border: 1px solid rgba(100, 200, 150, 0.4);
color: rgba(150, 255, 200, 0.9);
}
&.withdrawal {
background: rgba(200, 150, 100, 0.2);
border: 1px solid rgba(200, 150, 100, 0.4);
color: rgba(255, 200, 150, 0.9);
}
&.transfer {
background: rgba(100, 150, 200, 0.2);
border: 1px solid rgba(100, 150, 200, 0.4);
color: rgba(150, 200, 255, 0.9);
}
}
.transaction-amount {
font-size: 1rem;
font-weight: 600;
&.positive {
color: rgba(100, 200, 150, 1);
}
&.negative {
color: rgba(220, 100, 100, 1);
}
}
}
.transaction-details {
display: flex;
justify-content: space-between;
align-items: center;
.transaction-time {
font-size: 0.7rem;
color: rgba(100, 150, 200, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
@media (max-width: 1400px) {
.bank-content {
grid-template-columns: 280px 1fr 300px;
}
}
@media (max-width: 1200px) {
.bank-content {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.panel-main {
grid-column: 1;
}
}

View File

@ -1,179 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Banking Services</title>
<!-- <script src="store.js"></script> -->
<!-- <link rel="stylesheet" href="bank.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
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.js",
),
]).then(([css, storeJs, bankJs]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const store = document.createElement("script");
store.text = storeJs;
document.head.appendChild(store);
const bank = document.createElement("script");
bank.text = bankJs;
document.head.appendChild(bank);
});
</script>
</head>
<body>
<div class="bank-container">
<!-- Header Section -->
<div class="bank-header">
<div class="bank-logo">
<!-- <img class="logo-icon" src="public/fdic.png" alt="Bank Logo" width="50"> -->
</div>
<div class="bank-info">
<h1 class="bank-title">Banking Services</h1>
<p class="bank-subtitle">Secure Financial Management</p>
</div>
<div class="header-actions">
<button class="action-btn close-btn">Close</button>
</div>
</div>
<!-- Main Content -->
<div class="bank-content">
<!-- Left Panel - Accounts -->
<div class="bank-panel">
<div class="panel-header">
<h2 class="panel-title">Your Accounts</h2>
</div>
<div class="panel-content">
<!-- Cash Account -->
<div class="account-card">
<div class="account-header">
<div class="account-info">
<span class="account-name">Cash</span>
<span class="account-type">Physical Currency</span>
</div>
</div>
<div class="account-balance">
<span class="balance-label">Available</span>
<span class="balance-amount">$2,500</span>
</div>
</div>
<!-- Bank Account -->
<div class="account-card">
<div class="account-header">
<div class="account-info">
<span class="account-name">Bank Account</span>
<span class="account-type">Savings • Protected</span>
</div>
</div>
<div class="account-balance">
<span class="balance-label">Available</span>
<span class="balance-amount">$45,750</span>
</div>
</div>
<!-- Organization Account -->
<div class="account-card">
<div class="account-header">
<div class="account-info">
<span class="account-name">Organization</span>
<span class="account-type">Shared • View Only</span>
</div>
</div>
<div class="account-balance">
<span class="balance-label">Available</span>
<span class="balance-amount">$125,000</span>
</div>
</div>
</div>
</div>
<!-- Center Panel - Actions -->
<div class="bank-panel panel-main">
<div class="panel-header">
<h2 class="panel-title">Quick Actions</h2>
</div>
<div class="panel-content">
<!-- Transfer Form -->
<div class="action-section">
<h3 class="section-title">Transfer Funds</h3>
<div class="transfer-form">
<div class="form-group">
<label class="form-label">From</label>
<select class="form-select" id="transferFrom">
<option value="bank" selected>Bank Account</option>
<option value="cash">Cash</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Amount</label>
<input type="number" class="form-input" id="amount" placeholder="0.00" min="0"
step="0.01">
</div>
<div class="form-group" id="playerIdGroup" style="display: none;">
<label class="form-label">Select Player</label>
<select class="form-select" id="playerId">
<option value="" disabled selected>Select a player...</option>
</select>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="action-section">
<h3 class="section-title">Quick Access</h3>
<div class="quick-actions">
<button class="quick-action-btn" data-action="deposit-amount">
<span class="quick-action-label">Deposit</span>
</button>
<button class="quick-action-btn" data-action="deposit">
<span class="quick-action-label">Deposit All Cash</span>
</button>
<button class="quick-action-btn" data-action="withdraw">
<span class="quick-action-label">Withdraw</span>
</button>
<button class="quick-action-btn" id="transferBtn">
<span class="quick-action-label">Transfer Funds</span>
</button>
</div>
</div>
</div>
</div>
<!-- Right Panel - Recent Transactions -->
<div class="bank-panel">
<div class="panel-header">
<h2 class="panel-title">Recent Transactions</h2>
</div>
<div class="panel-content">
<div class="transaction-list">
</div>
</div>
</div>
</div>
</div>
<!-- <script src="bank.js"></script> -->
</body>
</html>

View File

@ -1,281 +0,0 @@
/**
* Banking Interface
* Handles transfers, deposits, withdrawals, and account management
*/
// ============================================================================
// INITIALIZATION
// ============================================================================
function initBank() {
setupEventHandlers();
// Subscribe to store updates
if (typeof store !== 'undefined') {
store.subscribe(() => {
updateBalances();
renderTransactions();
});
}
// Initial render
updateBalances();
renderTransactions();
console.log('[Bank] Interface initialized');
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
function setupEventHandlers() {
// Close button
const closeBtn = document.querySelector('.close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
sendEvent('bank::close', {});
});
}
// Transfer form
const transferBtn = document.getElementById('transferBtn');
const transferFrom = document.getElementById('transferFrom');
const amount = document.getElementById('amount');
const playerId = document.getElementById('playerId');
const playerIdGroup = document.getElementById('playerIdGroup');
// Always show player ID field since transfer is only to players
if (playerIdGroup) {
playerIdGroup.style.display = 'flex';
}
// Transfer button
if (transferBtn) {
transferBtn.addEventListener('click', () => {
const from = transferFrom.value;
const transferAmount = parseFloat(amount.value);
if (!transferAmount || transferAmount <= 0) {
console.log('Please enter a valid amount');
return;
}
if (!playerId.value) {
console.log('Please enter a player ID');
return;
}
const currentState = store.getState();
const fromAccountBalance = currentState.accounts[from];
if (transferAmount > fromAccountBalance) {
console.log('Insufficient funds');
return;
}
const transferData = {
from: from,
amount: transferAmount,
target: playerId.value
};
sendEvent('bank::transfer', transferData);
// Dispatch to store to update UI
store.dispatch(transfer(from, transferAmount, 'player'));
// Clear form
amount.value = '';
playerId.value = '';
});
}
// Quick action buttons
const quickActionBtns = document.querySelectorAll('.quick-action-btn');
quickActionBtns.forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
const currentState = store.getState();
switch (action) {
case 'deposit-amount':
const depositAmountStr = document.getElementById('amount').value;
if (depositAmountStr && parseFloat(depositAmountStr) > 0) {
const depositAmount = parseFloat(depositAmountStr);
if (depositAmount > currentState.accounts.cash) {
console.log('Insufficient cash');
return;
}
sendEvent('bank::deposit', { amount: depositAmount });
store.dispatch(deposit(depositAmount));
document.getElementById('amount').value = '';
} else {
console.log('Please enter a valid amount');
}
break;
case 'deposit':
const cashBalance = currentState.accounts.cash;
if (cashBalance <= 0) {
console.log('No cash to deposit');
return;
}
sendEvent('bank::deposit', { amount: cashBalance });
store.dispatch(deposit(cashBalance));
break;
case 'withdraw':
const amountStr = document.getElementById('amount').value;
if (amountStr && parseFloat(amountStr) > 0) {
const withdrawAmount = parseFloat(amountStr);
sendEvent('bank::withdraw', { amount: withdrawAmount });
store.dispatch(withdraw(withdrawAmount));
document.getElementById('amount').value = '';
} else {
console.log('Please enter a valid amount');
}
break;
default:
console.log('Invalid action');
break;
}
});
});
}
// ============================================================================
// UI UPDATES
// ============================================================================
function updateBalances() {
const currentState = store.getState();
const balanceElements = document.querySelectorAll('.balance-amount');
// The HTML structure has 3 account cards.
// 0: Cash, 1: Bank, 2: Org
if (balanceElements.length >= 3) {
balanceElements[0].textContent = `$${currentState.accounts.cash.toLocaleString()}`;
balanceElements[1].textContent = `$${currentState.accounts.bank.toLocaleString()}`;
balanceElements[2].textContent = `$${currentState.accounts.org.toLocaleString()}`;
}
// Update form options
const transferFrom = document.getElementById('transferFrom');
if (transferFrom) {
const currentSelection = transferFrom.value;
transferFrom.innerHTML = `
<option value="cash">Cash</option>
<option value="bank" selected>Bank Account</option>
`;
if (currentSelection && (currentSelection === 'cash' || currentSelection === 'bank')) {
transferFrom.value = currentSelection;
}
}
// Update player list
const playerSelect = document.getElementById('playerId');
if (playerSelect && currentState.accounts.players) {
const currentPlayerSelection = playerSelect.value;
const players = currentState.accounts.players;
const currentPlayerUid = currentState.uid;
// Clear existing options
playerSelect.innerHTML = '<option value="">Select Player...</option>';
// Handle hashmap structure from Arma (UID -> {name, uid})
if (players && typeof players === 'object') {
// Convert hashmap to array and iterate
Object.keys(players).forEach(uid => {
// Skip current player to prevent self-transfers
if (uid === currentPlayerUid) {
return;
}
const playerData = players[uid];
if (playerData && playerData.name) {
const option = document.createElement('option');
option.value = uid;
option.textContent = playerData.name;
playerSelect.appendChild(option);
}
});
}
if (currentPlayerSelection) {
// Verify if the selected player is still in the list
const optionExists = Array.from(playerSelect.options).some(opt => opt.value === currentPlayerSelection);
if (optionExists) {
playerSelect.value = currentPlayerSelection;
}
}
}
}
function renderTransactions() {
const transactionList = document.querySelector('.transaction-list');
if (!transactionList) return;
transactionList.innerHTML = '';
const currentState = store.getState();
currentState.transactions.forEach((transaction, index) => {
const item = document.createElement('div');
item.className = 'transaction-item';
// Deposits are gains (green), Withdrawals and Transfers are losses (red)
const isGain = transaction.type === 'Deposit';
const amountClass = isGain ? 'positive' : 'negative';
const displayAmount = isGain ? `+$${transaction.amount.toLocaleString()}` : `-$${Math.abs(transaction.amount).toLocaleString()}`;
// Map transaction types to CSS classes
const typeClassMap = {
'Deposit': 'deposit',
'Withdraw': 'withdrawal',
'Transfer': 'transfer'
};
const typeClass = typeClassMap[transaction.type] || transaction.type.toLowerCase();
item.innerHTML = `
<div class="transaction-header">
<span class="transaction-type ${typeClass}">${transaction.type}</span>
<span class="transaction-amount ${amountClass}">${displayAmount}</span>
</div>
<div class="transaction-details">
<span class="transaction-time">${transaction.date}</span>
</div>
`;
transactionList.appendChild(item);
});
}
// ============================================================================
// ARMA 3 INTEGRATION
// ============================================================================
/**
* Sends an event to Arma 3
* @param {string} event - Event name
* @param {Object} data - Event data
*/
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', initBank);
} else {
initBank();
}

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>FORGE Banking Console</title><script>window.ForgeSiteConfig={addonName:"bank",logLabel:"Bank UI",styles:["bank-ui.css"],commonScripts:["forge-webui.js"],scripts:["bank-ui.js"]},function(){const e="../../../common/ui/_site/forge-site-loader.js";("undefined"!=typeof A3API&&A3API&&"function"==typeof A3API.RequestFile?A3API.RequestFile("forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js"):fetch(e).then(o=>{if(!o.ok)throw new Error("Failed to load "+e);return o.text()})).then(function(e){const o=document.createElement("script");o.text=e,document.head.appendChild(o)}).catch(e=>{console.error("[Bank UI] Failed to load Forge site loader.",e)})}()</script></head><body><div id="app"></div></body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

View File

@ -1,270 +0,0 @@
/**
* Banking Application Store
* Redux-like state management for bank and ATM interfaces
*/
// ============================================================================
// REDUX CORE IMPLEMENTATION
// ============================================================================
/**
* Creates a Redux-like store.
* @param {Function} reducer - A function that returns the next state tree
* @returns {Object} The store object with methods: getState, dispatch, subscribe
*/
function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter(l => l !== listener);
};
};
// Initialize state
dispatch({});
return { getState, dispatch, subscribe };
}
// ============================================================================
// STATE
// ============================================================================
const initialState = {
uid: '',
accounts: {
bank: 0,
cash: 0,
org: 0
},
pin: '1234',
transactions: []
};
// ============================================================================
// ACTION TYPES
// ============================================================================
const DEPOSIT = 'DEPOSIT';
const WITHDRAW = 'WITHDRAW';
const TRANSFER = 'TRANSFER';
const UPDATE_ACCOUNTS = 'UPDATE_ACCOUNTS';
const UPDATE_PIN = 'UPDATE_PIN';
// ============================================================================
// ACTION CREATORS
// ============================================================================
const deposit = (amount) => ({
type: DEPOSIT,
payload: amount
});
const withdraw = (amount) => ({
type: WITHDRAW,
payload: amount
});
const transfer = (from, amount, target) => ({
type: TRANSFER,
from: from,
payload: amount,
target: target
});
const updateAccounts = (accounts) => ({
type: UPDATE_ACCOUNTS,
payload: accounts
});
const updatePin = (pin) => ({
type: UPDATE_PIN,
payload: pin
});
// ============================================================================
// REDUCER
// ============================================================================
function appReducer(state = initialState, action) {
switch (action.type) {
case DEPOSIT:
if (state.accounts.cash < action.payload) {
console.warn('Insufficient cash!');
return state;
}
return {
...state,
accounts: {
...state.accounts,
bank: state.accounts.bank + action.payload,
cash: state.accounts.cash - action.payload
},
transactions: [
...state.transactions,
{
type: 'Deposit',
amount: action.payload,
date: new Date().toLocaleString()
}
]
};
case WITHDRAW:
if (state.accounts.bank < action.payload) {
console.warn('Insufficient funds!');
return state;
}
return {
...state,
accounts: {
...state.accounts,
bank: state.accounts.bank - action.payload,
cash: state.accounts.cash + action.payload
},
transactions: [
...state.transactions,
{
type: 'Withdraw',
amount: action.payload,
date: new Date().toLocaleString()
}
]
};
case TRANSFER:
const fromAccount = action.from;
if (state.accounts[fromAccount] < action.payload) {
console.warn('Insufficient funds!');
return state;
}
const newAccounts = { ...state.accounts };
newAccounts[fromAccount] -= action.payload;
return {
...state,
accounts: newAccounts,
transactions: [
...state.transactions,
{
type: 'Transfer',
amount: action.payload,
from: fromAccount,
target: action.target,
date: new Date().toLocaleString()
}
]
};
case UPDATE_ACCOUNTS:
return {
...state,
accounts: {
...state.accounts,
...action.payload
}
};
case UPDATE_PIN:
return {
...state,
pin: String(action.payload)
};
case 'SET_UID':
return {
...state,
uid: action.payload
};
default:
return state;
}
}
// ============================================================================
// STORE INSTANCE
// ============================================================================
const store = createStore(appReducer);
// ============================================================================
// ARMA 3 INTEGRATION
// ============================================================================
/**
* Sends an event to Arma 3
* @param {string} event - Event name
* @param {Object} data - Event data
*/
function sendEvent(event, data) {
if (typeof A3API !== 'undefined') {
A3API.SendAlert(JSON.stringify({
event: event,
data: data
}));
} else {
console.log('Event:', event, 'Data:', data);
}
}
/**
* Syncs account data from Arma 3 into the store
* @param {Object} data - Account data from Arma 3
*/
function syncDataFromArma(data) {
if (data && typeof data === 'object') {
const accounts = {};
if (data.cash !== undefined) accounts.cash = data.cash;
if (data.bank !== undefined) accounts.bank = data.bank;
if (data.org !== undefined) accounts.org = data.org;
if (data.players !== undefined) accounts.players = data.players;
if (Object.keys(accounts).length > 0) {
store.dispatch(updateAccounts(accounts));
}
// Update UID if provided
if (data.uid !== undefined && data.uid !== store.getState().uid) {
store.dispatch({ type: 'SET_UID', payload: data.uid });
}
// Update pin if provided
if (data.pin !== undefined) {
store.dispatch(updatePin(data.pin));
}
console.log('[Store] Synced data from Arma:', store.getState().accounts);
} else {
console.warn('[Store] Invalid data received:', data);
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
// Request initial data from Arma on load
if (typeof A3API !== 'undefined') {
// Delay request slightly to ensure everything is loaded
setTimeout(() => {
sendEvent('bank::sync', {});
}, 100);
}
// Expose sync function globally for Arma to call
if (typeof window !== 'undefined') {
window.syncDataFromArma = syncDataFromArma;
}

View File

@ -0,0 +1,116 @@
(function () {
const ForgeWebUI = window.ForgeWebUI;
const BankApp = window.BankApp;
const islandDefinitions = [
{
id: "bank-notice-root",
preserveScroll: false,
render: () => BankApp.componentFns.NoticeLayer(),
},
{
id: "bank-sidebar-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSidebar(),
},
{
id: "bank-page-header-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankPageHeader(),
},
{
id: "bank-summary-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSummarySection(),
},
{
id: "bank-action-sections-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankActionSections(),
},
{
id: "bank-support-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSupportSection(),
},
{
id: "bank-history-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankHistorySection(),
},
{
id: "bank-atm-root",
preserveScroll: false,
render: () => BankApp.componentFns.ATMView(),
},
{
id: "bank-footer-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankFooter(),
},
];
function createIslandManager() {
const mounts = new Map();
function sync() {
islandDefinitions.forEach((definition) => {
const container = document.getElementById(definition.id);
const current = mounts.get(definition.id);
if (!container) {
if (current) {
current.handle.dispose();
mounts.delete(definition.id);
}
return;
}
if (current && current.container === container) {
return;
}
if (current) {
current.handle.dispose();
}
const handle = ForgeWebUI.mount(container, definition.render, {
preserveScroll: definition.preserveScroll,
});
mounts.set(definition.id, {
container,
handle,
});
});
}
return {
sync,
};
}
const app = ForgeWebUI.createApp({
name: "bank",
root: "#app",
setup({ root }) {
const islandManager = createIslandManager();
ForgeWebUI.mount(root, () => BankApp.components.App(), {
preserveScroll: false,
});
if (BankApp.bridge) {
BankApp.bridge.notifyReady();
}
ForgeWebUI.effect(() => {
BankApp.store.getMode();
requestAnimationFrame(() => {
islandManager.sync();
});
});
},
});
app.start();
})();

View File

@ -0,0 +1,51 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const store = BankApp.store;
const bridge = window.ForgeWebUI.createBridge({
closeEvent: "bank::close",
globalName: "ForgeBridge",
readyEvent: "bank::ready",
});
function hydrate(payloadData) {
BankApp.data.applyHydratePayload(payloadData);
store.hydrateFromPayload(payloadData);
}
bridge.on("bank::hydrate", hydrate);
bridge.on("bank::sync", hydrate);
bridge.on("bank::notice", (payloadData) => {
if (BankApp.actions) {
BankApp.actions.showNotice(
payloadData.type || "error",
payloadData.message || "Bank notice received.",
);
}
});
BankApp.bridge = {
notifyReady() {
return bridge.ready({ loaded: true });
},
receive: bridge.receive,
requestClose() {
return bridge.close({});
},
requestDeposit(payload) {
return bridge.send("bank::deposit::request", payload);
},
requestDepositEarnings(payload) {
return bridge.send("bank::depositEarnings::request", payload);
},
requestRefresh() {
return bridge.send("bank::refresh", {});
},
requestTransfer(payload) {
return bridge.send("bank::transfer::request", payload);
},
requestWithdraw(payload) {
return bridge.send("bank::withdraw::request", payload);
},
sendEvent: bridge.send,
};
})();

View File

@ -0,0 +1,104 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar;
const store = BankApp.store;
const actions = BankApp.actions;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.NoticeLayer = function NoticeLayer() {
const notice = store.getNotice();
if (!notice.text) {
return null;
}
return h(
"div",
{ className: "bank-notice-stack" },
h(
"div",
{
className:
notice.type === "error"
? "bank-notice is-error"
: "bank-notice is-success",
},
notice.text,
),
);
};
BankApp.components = BankApp.components || {};
BankApp.components.App = function App() {
const mode = store.getMode();
return h(
"div",
{ className: mode === "atm" ? "bank-shell is-atm" : "bank-shell" },
mode === "atm"
? null
: WindowTitleBar({
kicker: "FORGE Finance",
title: "Global Banking Network",
onClose: () => actions.closeBank(),
closeLabel: "Close banking interface",
}),
h("div", { id: "bank-notice-root" }),
mode === "atm"
? h("div", { id: "bank-atm-root" })
: [
h(
"div",
{
className: "bank-scroll-shell",
"data-preserve-scroll-id": "bank-page-scroll",
},
[
h(
"div",
{ className: "bank-layout" },
h("div", { id: "bank-sidebar-root" }),
h(
"main",
{ className: "bank-main" },
h(
"div",
{ className: "bank-page" },
h("div", {
id: "bank-page-header-root",
}),
h(
"p",
{ className: "bank-page-copy" },
"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console.",
),
h("div", {
className: "bank-page-divider",
}),
h(
"div",
{ className: "bank-page-body" },
h("div", {
id: "bank-summary-section-root",
}),
h("div", {
id: "bank-action-sections-root",
}),
h("div", {
id: "bank-support-section-root",
}),
h("div", {
id: "bank-history-section-root",
}),
),
),
),
),
h("div", { id: "bank-footer-root" }),
],
),
],
);
};
})();

View File

@ -0,0 +1,91 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account, session } = BankApp.data;
const { formatCurrency, statCard } = BankApp.componentFns;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankSidebar = function BankSidebar() {
store.getAccountVersion();
store.getSessionVersion();
return h(
"aside",
{ className: "bank-sidebar" },
h(
"section",
{ className: "bank-module" },
h(
"div",
{ className: "bank-module-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Account"),
h(
"h2",
{ className: "bank-section-title" },
"Balances",
),
),
h("span", { className: "bank-pill" }, "Live"),
),
h(
"div",
{ className: "bank-summary-grid" },
statCard("Bank", formatCurrency(account.bank), "accent"),
statCard("Cash", formatCurrency(account.cash)),
statCard(
"Earnings",
formatCurrency(account.earnings),
account.earnings > 0 ? "warning" : "",
),
statCard(
"Org Funds",
formatCurrency(session.orgFunds),
session.orgFunds > 0 ? "success" : "",
),
),
),
h(
"section",
{ className: "bank-module" },
h(
"div",
{ className: "bank-module-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Profile"),
h(
"h2",
{ className: "bank-section-title" },
"Account Holder",
),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.refreshBank(),
},
"Refresh",
),
),
h(
"div",
{ className: "bank-profile-stack" },
statCard("Name", session.playerName || "Unknown"),
statCard("UID", session.uid || "-"),
statCard(
"Organization",
session.orgName || "No active organization",
),
),
),
);
};
})();

View File

@ -0,0 +1,72 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const { account, session } = BankApp.data;
const { formatCurrency } = BankApp.componentFns;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankFooter = function BankFooter() {
store.getAccountVersion();
store.getSessionVersion();
const sections = [
{
title: "Banking Resources",
items: [
"Account Access Policy",
"Transfer & Wire Guidelines",
"Cash Handling Schedule",
"Terminal Security Notice",
],
},
{
title: "Bank Support",
items: session.orgName
? [
`Organization: ${session.orgName}`,
`Treasury Reference: ${formatCurrency(session.orgFunds)}`,
`${session.transferTargets.length} transfer recipient(s) currently visible.`,
`Primary Ledger: ${formatCurrency(account.bank)}`,
]
: [
"Organization: No active treasury link",
`${session.transferTargets.length} transfer recipient(s) currently visible.`,
`Primary Ledger: ${formatCurrency(account.bank)}`,
`Cash On Hand: ${formatCurrency(account.cash)}`,
],
},
];
return h(
"footer",
{ className: "bank-footer-bar" },
h(
"div",
{ className: "bank-footer" },
...sections.map((section) =>
h(
"div",
{ className: "bank-footer-block" },
h(
"h3",
{ className: "bank-footer-title" },
section.title,
),
h(
"ul",
{ className: "bank-footer-list" },
...(section.items || []).map((item) =>
h(
"li",
{ className: "bank-footer-copy" },
item,
),
),
),
),
),
),
);
};
})();

View File

@ -0,0 +1,189 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const { account } = BankApp.data;
function formatCurrency(value) {
return `$${Math.round(Number(value || 0)).toLocaleString()}`;
}
function pending(actionName) {
return store.getPendingAction() === actionName;
}
function statCard(label, value, tone = "") {
return h(
"div",
{
className: tone
? `bank-stat-card is-${tone}`
: "bank-stat-card",
},
h("span", { className: "bank-stat-label" }, label),
h("span", { className: "bank-stat-value" }, value),
);
}
function metricCard(label, value, copy, tone = "") {
return h(
"div",
{
className: tone
? `bank-metric-card is-${tone}`
: "bank-metric-card",
},
h("span", { className: "bank-eyebrow" }, label),
h("span", { className: "bank-metric-value" }, value),
h("span", { className: "bank-metric-copy" }, copy),
);
}
function pinIndicators(value) {
const pin = String(value || "");
return h(
"div",
{ className: "bank-pin-indicators" },
[0, 1, 2, 3].map((index) =>
h("span", {
className:
index < pin.length
? "bank-pin-indicator is-filled"
: "bank-pin-indicator",
}),
),
);
}
function readInputValue(id) {
return document.getElementById(id)?.value || "";
}
function clearInputValue(id) {
const input = document.getElementById(id);
if (input) {
input.value = "";
}
}
function keypad(onDigit, onBackspace, onClear, onEnter) {
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
return h(
"div",
{ className: "bank-keypad" },
keys.map((digit) =>
h(
"button",
{
type: "button",
className: "bank-key",
onClick: () => onDigit(digit),
},
digit,
),
),
h(
"button",
{
type: "button",
className: "bank-key is-muted",
onClick: onClear,
},
"C",
),
h(
"button",
{
type: "button",
className: "bank-key",
onClick: () => onDigit("0"),
},
"0",
),
h(
"button",
{
type: "button",
className: "bank-key is-accent",
onClick: onEnter,
},
"Enter",
),
h(
"button",
{
type: "button",
className: "bank-key is-wide",
onClick: onBackspace,
},
"Backspace",
),
);
}
function transactionRows() {
const transactions = Array.isArray(account.transactions)
? account.transactions
: [];
if (transactions.length === 0) {
return h(
"div",
{ className: "bank-empty-state" },
h("h3", { className: "bank-empty-title" }, "No transactions"),
h(
"p",
{ className: "bank-empty-copy" },
"Deposits, withdrawals, and transfers will appear here after the account begins moving funds.",
),
);
}
return h(
"div",
{ className: "bank-history-list" },
transactions
.slice(0, 8)
.map((entry) =>
h(
"div",
{ className: "bank-history-row" },
h(
"div",
{ className: "bank-history-copy" },
h(
"span",
{ className: "bank-history-title" },
entry.type || "Transaction",
),
h(
"span",
{ className: "bank-history-meta" },
entry.date || "Pending timestamp",
),
),
h(
"span",
{ className: "bank-history-value" },
formatCurrency(entry.amount || 0),
),
),
),
);
}
BankApp.componentFns = BankApp.componentFns || {};
Object.assign(BankApp.componentFns, {
clearInputValue,
formatCurrency,
keypad,
metricCard,
pending,
pinIndicators,
readInputValue,
statCard,
transactionRows,
});
})();

View File

@ -0,0 +1,44 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const defaultSession = {
mode: "bank",
orgFunds: 0,
orgName: "",
playerName: "",
transferTargets: [],
uid: "",
};
const defaultAccount = {
bank: 0,
cash: 0,
earnings: 0,
pin: "1234",
transactions: [],
};
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));
}
BankApp.data = {
account: Object.assign({}, defaultAccount),
session: Object.assign({}, defaultSession),
applyHydratePayload(payload) {
replaceObject(
this.session,
Object.assign({}, defaultSession, payload?.session || {}),
);
replaceObject(
this.account,
Object.assign({}, defaultAccount, payload?.account || {}),
);
},
};
})();

View File

@ -0,0 +1,238 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account } = BankApp.data;
const { formatCurrency, keypad, pinIndicators } = BankApp.componentFns;
function atmMenuCard() {
return h(
"div",
{ className: "bank-atm-action-grid" },
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("withdraw"),
},
"Withdraw Cash",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("deposit"),
},
"Deposit Cash",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("balance"),
},
"Check Balance",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.closeBank(),
},
"Exit Terminal",
),
);
}
function atmAmountMenu(kind) {
const label = kind === "deposit" ? "Deposit" : "Withdraw";
const amounts = [20, 50, 100, 500];
return h(
"div",
{ className: "bank-atm-action-grid" },
amounts.map((amount) =>
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.requestAtmAmount(kind, amount),
},
`${label} ${formatCurrency(amount)}`,
),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () =>
actions.selectAtmView(
kind === "deposit"
? "customDeposit"
: "customWithdraw",
),
},
"Custom Amount",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("menu"),
},
"Back",
),
);
}
function atmCustomAmount(kind) {
const label = kind === "deposit" ? "Deposit" : "Withdraw";
return h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-pin-display" },
store.getCustomAmount()
? formatCurrency(store.getCustomAmount())
: "$0",
),
keypad(
actions.appendCustomAmountDigit,
actions.backspaceCustomAmount,
actions.clearCustomAmount,
() => actions.submitCustomAmount(kind),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("menu"),
},
`Cancel ${label}`,
),
);
}
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.ATMView = function ATMView() {
store.getAccountVersion();
const atmViewName = store.getAtmView();
const enteredPin = String(store.getEnteredPin() || "");
let title = "Terminal Access";
let copy =
"Authenticate with the four-digit account PIN before using the terminal.";
let content = null;
switch (atmViewName) {
case "menu":
title = "ATM Menu";
copy =
"Select a banking action. The ATM can deposit, withdraw, and show the live account balance.";
content = atmMenuCard();
break;
case "withdraw":
title = "Withdraw Cash";
copy =
"Choose a preset amount or enter a custom amount for withdrawal.";
content = atmAmountMenu("withdraw");
break;
case "deposit":
title = "Deposit Cash";
copy =
"Move cash on hand back into the main bank balance from the terminal.";
content = atmAmountMenu("deposit");
break;
case "customWithdraw":
title = "Custom Withdraw";
copy = "Enter the exact withdrawal amount.";
content = atmCustomAmount("withdraw");
break;
case "customDeposit":
title = "Custom Deposit";
copy = "Enter the exact deposit amount.";
content = atmCustomAmount("deposit");
break;
case "balance":
title = "Available Balance";
copy = "Current bank balance available at this terminal.";
content = h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-balance-display" },
formatCurrency(account.bank),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("menu"),
},
"Return to Menu",
),
);
break;
default:
content = h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-pin-display" },
pinIndicators(enteredPin),
),
keypad(
actions.appendPinDigit,
actions.backspacePin,
actions.clearPin,
actions.submitPin,
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.closeBank(),
},
"Exit Terminal",
),
);
break;
}
return h(
"div",
{ className: "bank-atm-shell" },
h(
"section",
{ className: "bank-atm-panel" },
h(
"div",
{ className: "bank-panel-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "ATM"),
h("h1", { className: "bank-title" }, title),
),
h("span", { className: "bank-pill" }, "Secure Terminal"),
),
h("p", { className: "bank-panel-copy" }, copy),
content,
),
);
};
})();

View File

@ -0,0 +1,321 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account, session } = BankApp.data;
const {
clearInputValue,
formatCurrency,
metricCard,
pending,
readInputValue,
transactionRows,
} = BankApp.componentFns;
function trackAccount() {
store.getAccountVersion();
}
function trackSession() {
store.getSessionVersion();
}
function pageHeader() {
trackSession();
return h(
"div",
{ className: "bank-page-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Treasury Desk"),
h("h1", { className: "bank-title" }, "Personal Banking"),
),
h(
"span",
{ className: "bank-pill" },
session.playerName || "Account Holder",
),
);
}
function summarySection() {
trackAccount();
trackSession();
return h(
"section",
{ className: "bank-page-section bank-summary-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Overview"),
h(
"h2",
{ className: "bank-section-title" },
"Financial Position",
),
),
h("span", { className: "bank-pill" }, "Banking Desk"),
),
h(
"div",
{ className: "bank-summary-band" },
metricCard(
"Primary Balance",
formatCurrency(account.bank),
"Available for transfers and withdrawals.",
"accent",
),
metricCard(
"Cash On Hand",
formatCurrency(account.cash),
"Funds currently carried by the player.",
),
metricCard(
"Pending Earnings",
formatCurrency(account.earnings),
"Ready to sweep into the main account ledger.",
account.earnings > 0 ? "warning" : "",
),
metricCard(
"Org Snapshot",
formatCurrency(session.orgFunds),
"Reference value pulled from the organization treasury.",
session.orgFunds > 0 ? "success" : "",
),
),
);
}
function actionSections() {
trackSession();
return h(
"div",
{ className: "bank-action-sections" },
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Movement"),
h(
"h2",
{ className: "bank-section-title" },
"Deposit / Withdraw",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h("input", {
id: "bank-amount-input",
className: "bank-input",
type: "number",
min: "1",
placeholder: "Enter amount",
}),
h(
"div",
{ className: "bank-action-row" },
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled: pending("deposit"),
onClick: () => {
const sent = actions.requestDeposit(
readInputValue("bank-amount-input"),
);
if (sent) {
clearInputValue("bank-amount-input");
}
},
},
pending("deposit") ? "Depositing..." : "Deposit",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
disabled: pending("withdraw"),
onClick: () => {
const sent = actions.requestWithdraw(
readInputValue("bank-amount-input"),
);
if (sent) {
clearInputValue("bank-amount-input");
}
},
},
pending("withdraw") ? "Withdrawing..." : "Withdraw",
),
),
),
),
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Transfer"),
h(
"h2",
{ className: "bank-section-title" },
"Wire Funds",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h(
"select",
{
id: "bank-transfer-target",
className: "bank-select",
},
h(
"option",
{ value: "" },
session.transferTargets.length > 0
? "Select recipient"
: "No available recipients",
),
session.transferTargets.map((entry) =>
h(
"option",
{ value: entry.uid },
entry.name || entry.uid,
),
),
),
h("input", {
id: "bank-transfer-amount",
className: "bank-input",
type: "number",
min: "1",
placeholder: "Enter transfer amount",
}),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled:
pending("transfer") ||
session.transferTargets.length === 0,
onClick: () => {
const sent = actions.requestTransfer(
readInputValue("bank-transfer-target"),
readInputValue("bank-transfer-amount"),
);
if (sent) {
clearInputValue("bank-transfer-amount");
}
},
},
pending("transfer")
? "Transferring..."
: "Transfer Funds",
),
),
),
);
}
function supportSection() {
trackAccount();
return h(
"div",
{ className: "bank-support-sections" },
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Sweep"),
h(
"h2",
{ className: "bank-section-title" },
"Deposit Earnings",
),
),
),
h(
"p",
{ className: "bank-card-copy" },
"Sweep pending earnings into the primary account when you want them reflected in the main balance.",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled:
pending("depositearnings") ||
Number(account.earnings || 0) <= 0,
onClick: () =>
actions.requestDepositEarnings(account.earnings),
},
pending("depositearnings")
? "Depositing..."
: "Deposit Earnings",
),
),
);
}
function historySection() {
trackAccount();
return h(
"section",
{ className: "bank-page-section bank-history-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "History"),
h(
"h2",
{ className: "bank-section-title" },
"Recent Transactions",
),
),
),
transactionRows(),
);
}
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankPageHeader = pageHeader;
BankApp.componentFns.BankSummarySection = summarySection;
BankApp.componentFns.BankActionSections = actionSections;
BankApp.componentFns.BankSupportSection = supportSection;
BankApp.componentFns.BankHistorySection = historySection;
})();

View File

@ -0,0 +1,343 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const store = BankApp.store;
let noticeTimer = null;
function getAccount() {
return BankApp.data?.account || {};
}
function getSession() {
return BankApp.data?.session || {};
}
function normalizeAmount(value) {
const amount = Math.floor(Number(value || 0));
return Number.isFinite(amount) ? amount : 0;
}
function showNotice(type, text) {
store.setNotice({ type, text });
if (noticeTimer) {
clearTimeout(noticeTimer);
}
noticeTimer = setTimeout(() => {
store.setNotice({ text: "", type: "" });
noticeTimer = null;
}, 3200);
}
function closeBank() {
const bridge = BankApp.bridge;
if (bridge && typeof bridge.requestClose === "function") {
const sent = bridge.requestClose();
if (sent) {
return true;
}
}
showNotice("error", "Bank bridge is unavailable.");
return false;
}
function refreshBank() {
const bridge = BankApp.bridge;
if (bridge && typeof bridge.requestRefresh === "function") {
const sent = bridge.requestRefresh();
if (sent) {
return true;
}
}
showNotice("error", "Bank refresh bridge is unavailable.");
return false;
}
function requestDeposit(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "Enter a valid deposit amount.");
return false;
}
if (amount > Number(account.cash || 0)) {
showNotice("error", "Cash on hand cannot cover that deposit.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestDeposit !== "function") {
showNotice("error", "Deposit bridge is unavailable.");
return false;
}
store.startAction("deposit");
const sent = bridge.requestDeposit({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Deposit bridge is unavailable.");
return false;
}
return true;
}
function requestWithdraw(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "Enter a valid withdrawal amount.");
return false;
}
if (amount > Number(account.bank || 0)) {
showNotice("error", "Bank balance cannot cover that withdrawal.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestWithdraw !== "function") {
showNotice("error", "Withdraw bridge is unavailable.");
return false;
}
store.startAction("withdraw");
const sent = bridge.requestWithdraw({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Withdraw bridge is unavailable.");
return false;
}
return true;
}
function requestTransfer(targetUid, amountValue) {
const amount = normalizeAmount(amountValue);
const session = getSession();
const account = getAccount();
const targetId = String(targetUid || "").trim();
if (!targetId) {
showNotice("error", "Select a transfer recipient.");
return false;
}
if (targetId === String(session.uid || "")) {
showNotice("error", "You cannot transfer funds to yourself.");
return false;
}
if (amount <= 0) {
showNotice("error", "Enter a valid transfer amount.");
return false;
}
if (amount > Number(account.bank || 0)) {
showNotice("error", "Bank balance cannot cover that transfer.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestTransfer !== "function") {
showNotice("error", "Transfer bridge is unavailable.");
return false;
}
store.startAction("transfer");
const sent = bridge.requestTransfer({
amount,
from: "bank",
target: targetId,
});
if (!sent) {
store.finishAction();
showNotice("error", "Transfer bridge is unavailable.");
return false;
}
return true;
}
function requestDepositEarnings(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "No earnings are available to deposit.");
return false;
}
if (amount > Number(account.earnings || 0)) {
showNotice(
"error",
"Pending earnings cannot cover that deposit request.",
);
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestDepositEarnings !== "function") {
showNotice("error", "Earnings bridge is unavailable.");
return false;
}
store.startAction("depositearnings");
const sent = bridge.requestDepositEarnings({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Earnings bridge is unavailable.");
return false;
}
return true;
}
function appendPinDigit(digit) {
const nextDigit = String(digit || "").trim();
if (!nextDigit) {
return;
}
const currentPin = String(store.getEnteredPin() || "");
if (currentPin.length >= 4) {
return;
}
store.setEnteredPin(currentPin + nextDigit);
}
function backspacePin() {
const currentPin = String(store.getEnteredPin() || "");
store.setEnteredPin(currentPin.slice(0, -1));
}
function clearPin() {
store.setEnteredPin("");
}
function submitPin() {
const enteredPin = String(store.getEnteredPin() || "");
const actualPin = String(getAccount().pin || "1234");
if (enteredPin.length !== 4) {
showNotice("error", "Enter your four-digit access PIN.");
return false;
}
if (enteredPin !== actualPin) {
clearPin();
showNotice("error", "Incorrect PIN.");
return false;
}
clearPin();
store.setAtmView("menu");
return true;
}
function selectAtmView(view) {
const nextView = String(view || "").trim();
if (!nextView) {
return false;
}
if (nextView === "pin") {
store.resetAtm();
return true;
}
store.setCustomAmount("");
store.setAtmView(nextView);
return true;
}
function appendCustomAmountDigit(digit) {
const nextDigit = String(digit || "").trim();
if (!nextDigit) {
return;
}
const currentValue = String(store.getCustomAmount() || "");
if (currentValue.length >= 7) {
return;
}
store.setCustomAmount(currentValue + nextDigit);
}
function backspaceCustomAmount() {
const currentValue = String(store.getCustomAmount() || "");
store.setCustomAmount(currentValue.slice(0, -1));
}
function clearCustomAmount() {
store.setCustomAmount("");
}
function submitCustomAmount(kind) {
const amount = normalizeAmount(store.getCustomAmount());
const nextKind = String(kind || "")
.trim()
.toLowerCase();
if (amount <= 0) {
showNotice("error", "Enter a valid transaction amount.");
return false;
}
const success =
nextKind === "deposit"
? requestDeposit(amount)
: requestWithdraw(amount);
if (success) {
store.setCustomAmount("");
store.setAtmView("menu");
}
return success;
}
function requestAtmAmount(kind, amount) {
const nextKind = String(kind || "")
.trim()
.toLowerCase();
const success =
nextKind === "deposit"
? requestDeposit(amount)
: requestWithdraw(amount);
if (success) {
store.setAtmView("menu");
}
return success;
}
BankApp.actions = {
appendCustomAmountDigit,
appendPinDigit,
backspaceCustomAmount,
backspacePin,
clearCustomAmount,
clearPin,
closeBank,
refreshBank,
requestAtmAmount,
requestDeposit,
requestDepositEarnings,
requestTransfer,
requestWithdraw,
selectAtmView,
showNotice,
submitCustomAmount,
submitPin,
};
})();

View File

@ -0,0 +1,63 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { createSignal } = BankApp.runtime;
class BankStore {
constructor() {
[this.getMode, this.setMode] = createSignal("bank");
[this.getNotice, this.setNotice] = createSignal({
text: "",
type: "",
});
[this.getPendingAction, this.setPendingAction] = createSignal("");
[this.getAtmView, this.setAtmView] = createSignal("pin");
[this.getEnteredPin, this.setEnteredPin] = createSignal("");
[this.getCustomAmount, this.setCustomAmount] = createSignal("");
[this.getAccountVersion, this.setAccountVersion] = createSignal(0);
[this.getSessionVersion, this.setSessionVersion] = createSignal(0);
}
finishAction() {
this.setPendingAction("");
}
hydrateFromPayload(payload) {
const mode = String(payload?.session?.mode || "bank")
.trim()
.toLowerCase();
const currentMode = this.getMode();
const currentAtmView = this.getAtmView();
this.setMode(mode === "atm" ? "atm" : "bank");
this.setPendingAction("");
this.setNotice({ text: "", type: "" });
this.setEnteredPin("");
this.setCustomAmount("");
this.setAccountVersion(this.getAccountVersion() + 1);
this.setSessionVersion(this.getSessionVersion() + 1);
if (mode === "atm") {
this.setAtmView(currentMode === "atm" ? currentAtmView : "pin");
return;
}
this.setAtmView("dashboard");
}
resetAtm() {
this.setEnteredPin("");
this.setCustomAmount("");
this.setAtmView("pin");
}
startAction(action) {
this.setPendingAction(
String(action || "")
.trim()
.toLowerCase(),
);
}
}
BankApp.store = new BankStore();
})();

View File

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

View File

@ -0,0 +1,590 @@
:root {
--bank-shell-bg: #f6f4ee;
--bank-surface: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
--bank-border: rgba(18, 54, 93, 0.12);
--bank-border-strong: rgba(18, 54, 93, 0.18);
--bank-text-main: #142f52;
--bank-text-muted: #6f86a3;
--bank-text-subtle: #8ea2bb;
--bank-accent: #275a8c;
--bank-accent-soft: #dfeaf9;
--bank-accent-line: rgba(39, 90, 140, 0.12);
--bank-shadow: 0 16px 30px rgba(18, 36, 57, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: transparent;
color: var(--bank-text-main);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
button,
input,
select {
font: inherit;
}
.bank-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--bank-shell-bg);
}
.bank-scroll-shell {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.bank-layout {
min-height: 100%;
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 1.25rem;
padding: 1.25rem;
flex: 1 0 auto;
}
.bank-sidebar,
.bank-main {
min-height: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bank-main {
overflow: visible;
}
.bank-module,
.bank-card,
.bank-atm-panel {
background: var(--bank-surface);
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
box-shadow: var(--bank-shadow);
}
.bank-module,
.bank-card,
.bank-atm-panel {
padding: 1rem;
display: flex;
flex-direction: column;
}
.bank-module-header,
.bank-card-header,
.bank-section-header,
.bank-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.bank-module-header,
.bank-card-header {
margin-bottom: 0.9rem;
}
.bank-page {
display: grid;
gap: 1.35rem;
padding: 0.1rem 0 0;
}
.bank-page-header {
padding-top: 0.4rem;
}
.bank-page-copy {
margin: 0;
color: var(--bank-text-muted);
line-height: 1.5;
max-width: 48rem;
}
.bank-page-divider {
border-top: 1px solid var(--bank-accent-line);
}
.bank-page-body {
display: grid;
gap: 1.25rem;
padding-bottom: 1.25rem;
}
.bank-page-section {
display: grid;
gap: 1rem;
padding: 1.15rem 1.2rem 1.25rem;
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
background: rgba(255, 255, 255, 0.72);
box-shadow: none;
}
.bank-title,
.bank-section-title {
margin: 0;
color: var(--bank-text-main);
letter-spacing: -0.02em;
}
.bank-title {
font-size: 1.7rem;
}
.bank-section-title {
font-size: 1.1rem;
}
.bank-eyebrow,
.bank-footer-title,
.bank-stat-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
color: var(--bank-text-subtle);
}
.bank-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.48rem 0.8rem;
border-radius: 999px;
background: var(--bank-accent-soft);
color: var(--bank-accent);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.bank-summary-grid,
.bank-profile-stack {
display: grid;
gap: 0.8rem;
}
.bank-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bank-stat-card,
.bank-metric-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.9rem;
border-radius: 0.95rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-stat-card.is-accent,
.bank-metric-card.is-accent {
background: linear-gradient(180deg, #edf4fe 0%, #dfeaf9 100%);
}
.bank-stat-card.is-success,
.bank-metric-card.is-success {
background: linear-gradient(180deg, #edf9f4 0%, #dff4ea 100%);
}
.bank-stat-card.is-warning,
.bank-metric-card.is-warning {
background: linear-gradient(180deg, #fdf7ea 0%, #f7edd4 100%);
}
.bank-stat-value,
.bank-metric-value {
min-width: 0;
color: var(--bank-text-main);
font-weight: 700;
overflow-wrap: anywhere;
}
.bank-stat-value {
font-size: 1rem;
}
.bank-metric-value {
font-size: 1.8rem;
letter-spacing: -0.03em;
}
.bank-metric-copy,
.bank-card-copy,
.bank-empty-copy,
.bank-footer-copy,
.bank-history-meta {
color: var(--bank-text-muted);
line-height: 1.45;
}
.bank-card-copy {
margin: 0 0 0.9rem;
}
.bank-summary-band {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.bank-action-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.bank-support-sections {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.bank-form-stack {
display: grid;
gap: 0.75rem;
}
.bank-input,
.bank-select {
width: 100%;
min-width: 0;
height: 2.9rem;
padding: 0 0.95rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
}
.bank-action-row {
display: flex;
gap: 0.75rem;
}
.bank-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.75rem 1rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease,
border-color 160ms ease;
}
.bank-btn:disabled {
opacity: 0.55;
cursor: default;
}
.bank-btn-primary {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-btn-primary:hover:not(:disabled) {
background: #354863;
border-color: #354863;
}
.bank-btn-secondary {
background: rgba(255, 255, 255, 0.82);
color: var(--bank-accent);
}
.bank-btn-secondary:hover:not(:disabled) {
background: #eef4fd;
}
.bank-history-list {
display: grid;
gap: 0.75rem;
}
.bank-history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-history-copy {
min-width: 0;
display: grid;
gap: 0.18rem;
}
.bank-history-title,
.bank-empty-title {
color: var(--bank-text-main);
font-weight: 700;
}
.bank-history-value {
white-space: nowrap;
font-weight: 700;
color: var(--bank-accent);
}
.bank-empty-state {
display: grid;
gap: 0.35rem;
padding: 1rem 0;
}
.bank-notice-stack {
position: fixed;
top: 1.2rem;
right: 1.5rem;
z-index: 12;
display: grid;
gap: 0.65rem;
}
.bank-notice {
max-width: 24rem;
padding: 0.85rem 1rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: #fff;
box-shadow: 0 14px 28px rgba(16, 34, 56, 0.14);
font-size: 0.92rem;
}
.bank-notice.is-success {
background: #ecfdf5;
border-color: #bbf7d0;
color: #166534;
}
.bank-notice.is-error {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.bank-footer-bar {
width: 100%;
margin-top: auto;
background: #1e293b;
color: #f8fafc;
}
.bank-footer {
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4rem;
padding: 3rem 1.25rem;
}
.bank-footer-block {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bank-footer-title {
margin: 0;
color: #f8fafc;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
padding-bottom: 0.5rem;
border-bottom: 1px solid #475569;
}
.bank-footer-list {
margin: 0;
padding: 0;
list-style: none;
}
.bank-atm-shell {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.bank-atm-panel {
width: min(100%, 560px);
display: grid;
gap: 1rem;
}
.bank-atm-stack {
display: grid;
gap: 1rem;
}
.bank-pin-display,
.bank-balance-display {
display: flex;
align-items: center;
justify-content: center;
min-height: 5rem;
padding: 1rem;
border-radius: 1rem;
border: 1px solid var(--bank-border-strong);
background: rgba(255, 255, 255, 0.68);
color: var(--bank-text-main);
text-align: center;
}
.bank-pin-display {
font-size: 2rem;
}
.bank-balance-display {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.bank-pin-indicators {
display: flex;
align-items: center;
justify-content: center;
gap: 0.9rem;
}
.bank-pin-indicator {
width: 1rem;
height: 1rem;
border-radius: 999px;
border: 2px solid var(--bank-accent);
background: transparent;
}
.bank-pin-indicator.is-filled {
background: var(--bank-accent);
}
.bank-keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.bank-key {
min-height: 3.2rem;
padding: 0.9rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
font-weight: 700;
}
.bank-key.is-muted {
background: #eef2f8;
color: var(--bank-text-muted);
}
.bank-key.is-accent {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-key.is-wide {
grid-column: span 3;
}
.bank-atm-action-grid {
display: grid;
gap: 0.75rem;
}
.bank-shell.is-atm {
background: transparent;
min-height: 100%;
justify-content: center;
}
.bank-shell.is-atm .bank-atm-shell {
flex: 1;
width: 100%;
min-height: 100%;
max-width: 100%;
}
.bank-footer-copy {
color: #cbd5e1;
line-height: 1.5;
margin: 0 0 0.75rem;
}
@media (max-width: 1200px) {
.bank-layout {
grid-template-columns: 1fr;
}
.bank-main {
overflow: visible;
}
}
@media (max-width: 900px) {
.bank-summary-band,
.bank-action-sections,
.bank-footer {
grid-template-columns: 1fr;
}
.bank-summary-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,38 @@
export default {
addonName: "bank",
title: "FORGE Banking Console",
logLabel: "Bank UI",
outputDir: "_site",
jsBundles: [
{
name: "Bank UI app",
output: "bank-ui.js",
sources: [
"src/runtime.js",
"src/data.js",
"src/registry/store.js",
"src/bridge.js",
"src/registry/events.js",
"src/components/common.js",
"src/components/BankSidebar.js",
"src/components/Footer.js",
"src/pages/BankView.js",
"src/pages/ATMView.js",
"src/components/AppShell.js",
"src/bootstrap.js",
],
},
],
cssBundles: [
{
name: "Bank UI styles",
output: "bank-ui.css",
sources: ["src/styles.css"],
},
],
site: {
styles: ["bank-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["bank-ui.js"],
},
};

View File

@ -1,4 +1,5 @@
forge_client_common
===================
# forge_client_common
Common functionality shared between addons.
See [WEB_UI_FRAMEWORK.md](./WEB_UI_FRAMEWORK.md) for the proposed shared `CT_WEBBROWSER` UI framework layout and API.

View File

@ -0,0 +1,991 @@
# Web UI Framework Proposal
## Goal
Create a shared web UI framework inside `forge_client_common` that provides one browser runtime for all `CT_WEBBROWSER` interfaces:
- store
- bank
- garage
- org
- actor
- notifications
The framework should standardize:
- browser bootstrapping
- Arma to JS messaging
- JS to Arma messaging
- reactive state updates
- shared UI primitives
- asset loading
- teardown and remount behavior
## Why This Should Live In `common`
The current client web UIs already share the same underlying concerns:
- `A3API.RequestFile` for loading scripts and styles
- `A3API.SendAlert` for outbound events
- `ctrlWebBrowserAction ["ExecJS", ...]` for inbound events
- full-page rerender on every signal update
- duplicated runtime and bridge code across addons
That makes `forge_client_common` the right owner for:
- the browser runtime
- the bridge contract
- reusable DOM helpers
- shared components and styles
Each addon should keep only:
- app-specific state
- app-specific event names
- app-specific SQF handlers
- app-specific views and theme assets
## Constraints From `CT_WEBBROWSER`
This framework should be built for the actual browser host, not for a generic modern frontend stack.
- Browser engine should be treated as conservative Chromium/CEF.
- HTML is hosted inside the Arma browser control, not a normal web server app.
- Asset loading must work through `A3API.RequestFile`.
- Game integration must work through `A3API.SendAlert` and SQF `ExecJS`.
- Browser controls are opened and destroyed by UI displays, so mount/unmount must be explicit.
- Startup latency matters because players open these UIs interactively in-game.
## Design Principles
1. Keep the runtime small.
2. Avoid framework dependencies like React or Vue.
3. Prefer one shared bundle plus one app bundle per UI.
4. Support coarse-grained reactivity first, then targeted DOM patching where it matters.
5. Make the Arma bridge a first-class host adapter, not an afterthought.
6. Keep app logic plain JavaScript so views are easy to reason about.
7. Make every UI follow the same bootstrap contract.
## Proposed Ownership
### Common addon
`forge_client_common` should own:
- browser host adapter
- reactive runtime
- DOM renderer
- shared event bus
- base CSS tokens and utility classes
- shared components
- generic bootstrap helper
- SQF bridge base class
### Feature addons
Each feature addon should own:
- one app entrypoint
- feature store/state
- feature bridge schema
- feature views/components
- feature-specific CSS layer
- feature SQF bridge subclass/instance
## Proposed Folder Layout
```text
arma/client/addons/common/
ui/
src/
runtime.js
host.js
bridge.js
app.js
index.js
_site/
forge-webui.js
functions/
fnc_initWebUIBridge.sqf
fnc_openWebUI.sqf
fnc_sendWebUIEvent.sqf
README.md
WEB_UI_FRAMEWORK.md
```
Feature addon structure would then look like:
```text
arma/client/addons/org/
ui/
_site/
index.html
app.js
views/
components/
theme.css
functions/
fnc_initOrgUIBridge.sqf
fnc_openUI.sqf
fnc_handleUIEvents.sqf
```
## Runtime API Sketch
The shared runtime should expose a small API on `window.ForgeWebUI`.
### Core API
```js
ForgeWebUI = {
h,
text,
fragment,
signal,
computed,
effect,
batch,
mount,
unmount,
createApp,
createBridge,
createAssetLoader,
createNoticeCenter,
};
```
### Reactive primitives
```js
const count = signal(0);
const doubled = computed(() => count() * 2);
effect(() => {
console.log("count", count());
});
count.set(5);
```
Design notes:
- `signal()` returns a getter function with `.set()` and `.update()`.
- `computed()` caches until one of its dependencies changes.
- `effect()` is for bridge sync, timers, DOM subscriptions, and cleanup.
- `batch()` groups several writes into one render pass.
### DOM/rendering
```js
function CounterView() {
return h("button", {
onClick() {
count.update((value) => value + 1);
}
}, `Count: ${count()}`);
}
mount(document.getElementById("app"), CounterView);
```
The renderer should support:
- keyed child reconciliation
- event binding
- text node updates
- conditional sections
- list rendering
- SVG nodes
- mount cleanup
It should not rebuild the whole root on every write.
## App Bootstrap Contract
Every app should use the same bootstrap shape:
```js
const app = ForgeWebUI.createApp({
name: "org",
root: "#app",
setup({ host, bridge, assets, notices }) {
const store = createOrgStore();
bridge.on("org::sync", (payload) => {
store.hydrate(payload);
});
bridge.ready();
return () => OrgApp({ store, host, notices });
}
});
app.start();
```
Responsibilities:
- `createApp()` locates the root node
- waits for DOM readiness
- sets up host services
- mounts the view
- wires bridge event listeners
- exposes teardown hooks
## Host Adapter API
The Arma host layer should hide `A3API` details behind one consistent service.
```js
const host = {
isArma: true,
requestFile(path),
requestTexture(path, size),
send(event, data),
exec(name, data),
on(event, handler),
off(event, handler),
ready(data),
close(data),
};
```
Behavior:
- `send()` wraps `A3API.SendAlert(JSON.stringify(...))`
- `on()` and `off()` subscribe to messages injected from SQF
- `ready()` announces page readiness to SQF
- `close()` sends a standard close event
- if `A3API` is unavailable, fallback behavior supports local browser testing
## JS Bridge Contract
Each page should expose one stable bridge object to SQF:
```js
window.ForgeBridge.receive({
event: "org::sync",
data: { ... }
});
```
This replaces app-specific globals like:
- `StoreUIBridge`
- `OrgUIBridge`
Recommended interface:
```js
window.ForgeBridge = {
receive(payload),
receiveMany(events),
reset(),
ping(),
};
```
Feature apps should register handlers with the shared bridge:
```js
bridge.on("store::hydrate", handleHydrate);
bridge.on("store::checkout::success", handleCheckoutSuccess);
```
That removes duplicated payload parsing from each app bridge file.
## SQF Bridge Base Class
The SQF side should also be normalized in `common`.
### Shared base responsibilities
- find active browser control
- execute JS safely
- send `{ event, data }` payloads
- queue payloads until page ready
- flush pending payloads on ready
- standardize close handling
- standardize logging and diagnostics
### SQF API sketch
```sqf
GVAR(WebUIBridgeBaseClass) = compileFinal createHashMapFromArray [
["#type", "WebUIBridgeBaseClass"],
["#create", compileFinal {
_self set ["pendingEvents", []];
_self set ["isReady", false];
}],
["getActiveBrowserControl", compileFinal { ... }],
["execJS", compileFinal { ... }],
["sendEvent", compileFinal { ... }],
["queueEvent", compileFinal { ... }],
["flushPendingEvents", compileFinal { ... }],
["handleReady", compileFinal { ... }],
["handleClose", compileFinal { ... }]
];
```
Feature bridges like org or store would then extend only the behavior they need:
- payload building
- server RPC dispatch
- feature response mapping
## SQF Type Model With `createHashMapObject`
The SQF side should lean into `createHashMapObject` instead of using plain hash maps for everything.
This gives us:
- inheritance through `#base`
- explicit type tagging through `#type`
- constructors through `#create`
- cleanup through `#delete`
That is a strong fit for browser UI infrastructure because the UI layer already has clear object roles.
### Recommended types
At minimum, define these object families in `forge_client_common`:
- `IWebUIBridge`
- `IWebUIScreen`
- `IWebUIRequest`
- `IWebUISubscription`
Feature addons can then define their own types on top:
- `OrgUIBridge`
- `StoreUIBridge`
- `BankUIBridge`
- `GarageUIBridge`
### Example hierarchy
```sqf
private _webUIBridgeDeclaration = [
["#type", "IWebUIBridge"],
["#create", { ... }],
["getActiveBrowserControl", { ... }],
["sendEvent", { ... }],
["handleReady", { ... }],
["dispose", { ... }]
];
private _orgUIBridgeDeclaration = [
["#base", _webUIBridgeDeclaration],
["#type", "OrgUIBridge"],
["buildHydratePayload", { ... }],
["handleCreditResponse", { ... }]
];
```
Type checks then become straightforward:
```sqf
if ("IWebUIBridge" in (_bridge get "#type")) then {
_bridge call ["sendEvent", ["ui::ping", createHashMap]];
};
```
### Why Example 4 matters
Example 4 on the wiki shows the important lifecycle property:
- constructor creates a resource
- object holds that resource
- destructor deletes that resource when the object is released
That pattern maps directly to UI/session resources.
### Good uses of `#delete` in this framework
- clear pending request queues
- unregister display event handlers
- null out active browser control references
- stop polling/update loops
- remove temporary mission event handlers
- release temporary response trackers
### Example use: request/response object
```sqf
private _requestDeclaration = [
["#type", "IWebUIRequest"],
["#create", {
params ["_requestId", "_onTimeout"];
_self set ["requestId", _requestId];
_self set ["onTimeout", _onTimeout];
_self set ["isResolved", false];
}],
["resolve", {
_self set ["isResolved", true];
}],
["#delete", {
if !(_self getOrDefault ["isResolved", false]) then {
private _onTimeout = _self getOrDefault ["onTimeout", {}];
call _onTimeout;
};
}]
];
```
This is the same concept as Example 4:
- object owns a resource or responsibility
- when the object is released, cleanup happens automatically
## Lifecycle Guidance
Use destructors as a cleanup safety net, not as the only control path.
Reason:
- `#delete` runs when the last reference is removed
- that is useful, but not always the best moment for gameplay/UI logic
Recommended pattern:
1. expose an explicit `dispose` or `close` method
2. perform normal cleanup there
3. let `#delete` catch anything missed
That keeps UI shutdown deterministic while still benefiting from automatic cleanup.
## Typed Screen Objects
We can also model each open browser UI as a typed screen object instead of just storing a control reference.
Example:
```sqf
private _screenDeclaration = [
["#type", "IWebUIScreen"],
["#create", {
params ["_displayName", "_control"];
_self set ["displayName", _displayName];
_self set ["control", _control];
_self set ["isReady", false];
_self set ["pendingEvents", []];
}],
["markReady", {
_self set ["isReady", true];
}],
["queueEvent", { ... }],
["flushPendingEvents", { ... }],
["dispose", {
_self set ["pendingEvents", []];
_self set ["control", controlNull];
}]
];
```
That gives us a cleaner split:
- bridge object owns app-level behavior
- screen object owns one live browser control/session
- request objects own transient async work
## Recommended Application To Current Addons
The current org and store bridge objects already use `createHashMapObject`.
This should evolve into:
- one shared `IWebUIBridge` base declaration in `common`
- one shared `IWebUIScreen` declaration in `common`
- feature bridge types inheriting from `IWebUIBridge`
- optional transient request/session helper types where async cleanup matters
That will make the SQF side more explicit, easier to test, and safer around UI teardown.
## Event Naming
Keep namespaced events. The current event style is good.
Examples:
- `org::ready`
- `org::sync`
- `org::create::request`
- `store::checkout::request`
- `notifications::ready`
Standardize a small set of host-level events:
- `ui::ready`
- `ui::close`
- `ui::error`
- `ui::ping`
And keep feature events under their own namespace.
## State Model
The framework should support two store patterns:
### Local signal store
Good for:
- form state
- modal state
- selection state
- optimistic UI flags
### Domain store wrapper
Good for:
- hydrated server payloads
- catalog data
- actor action lists
- organization portal data
Recommended store API:
```js
function createStore(initialState) {
const state = signal(initialState);
return {
get state() {
return state();
},
patch(partial) {
state.set({ ...state(), ...partial });
},
replace(next) {
state.set(next);
}
};
}
```
## Component Update Model
The framework should update component subtrees, not the full UI root.
That means:
- no browser page reload
- no `innerHTML = ""` on the app root for every state change
- only components that read changed state should rerender
### Practical expectation
Examples:
- adding a member updates `MembersCard` and any member count badge
- granting a credit line updates `TreasuryCard` and the specific member row
- updating funds updates treasury summary components only
- showing a modal or notice updates only the overlay layer
## Store Contract
Each app store should expose three layers:
1. domain state signals
2. derived selectors/computed values
3. mutation methods
Recommended shape:
```js
function createOrgStore() {
const org = signal({
id: "",
name: "",
ownerUid: "",
});
const session = signal({
actorUid: "",
actorName: "",
role: "",
ceo: false,
});
const treasury = signal({
funds: 0,
reputation: 0,
creditLines: [],
});
const roster = signal({
members: [],
});
const ui = signal({
modal: null,
notices: [],
treasuryTab: "overview",
});
const memberCount = computed(() => roster().members.length);
const activeCreditCount = computed(() => treasury().creditLines.length);
return {
org,
session,
treasury,
roster,
ui,
memberCount,
activeCreditCount,
hydrate(payload) { ... },
addMember(member) { ... },
removeMember(memberUid) { ... },
upsertCreditLine(line) { ... },
setFunds(amount) { ... },
openModal(type, data) { ... },
closeModal() { ... },
};
}
```
### Rules
- component code reads signals directly from the store
- mutation methods are the only place that update domain state
- derived values use `computed()` instead of recalculating in every component
- UI state stays separate from domain state
## Component Contract
Components should be plain functions that subscribe only to the signals they read.
Example:
```js
function MembersCard({ store, actions }) {
const members = store.roster().members;
const canManageMembers = store.canManageMembers();
return Card({
title: "Members",
body: List({
items: members,
key: (member) => member.uid,
renderItem: (member) =>
MemberRow({
member,
canRemove: canManageMembers && !store.isProtectedMember(member),
onRemove: () => actions.removeMember(member.uid),
}),
}),
});
}
```
In this model:
- `MembersCard` rerenders when `roster().members` changes
- it does not rerender when treasury funds change
- `TreasuryCard` rerenders when `treasury()` changes
- modal components rerender when `ui().modal` changes
## Patch-Oriented Mutations
Interactive actions should prefer small patch events over full app hydration.
Recommended event examples:
- `org::member::added`
- `org::member::removed`
- `org::member::creditUpdated`
- `org::treasury::fundsUpdated`
- `org::notice::show`
Initial load can still use a hydrate event:
- `org::hydrate`
But actions like assigning credit lines should not require rebuilding the full portal payload.
Example:
```js
bridge.on("org::member::creditUpdated", ({ memberUid, memberName, amount }) => {
store.upsertCreditLine({
uid: memberUid,
member: memberName,
amount,
});
});
```
## List Reconciliation
To make targeted updates real, list rendering must be keyed.
Requirement:
- every repeated domain item must have a stable key
Examples:
- members use `uid`
- credit lines use `uid`
- assets use `className` or inventory id
- fleet entries use vehicle id
Without keyed reconciliation, a list change still forces the entire list DOM to be replaced.
## Org UI Example
Using the current organization portal as the model:
### `MembersCard`
Depends on:
- `store.roster().members`
- membership permission selectors
Should update when:
- a member is added
- a member is removed
- a member name or role changes
Should not update when:
- treasury funds change
- a modal opens
- a fleet item changes
### `TreasuryCard`
Depends on:
- `store.treasury().funds`
- `store.treasury().creditLines`
- treasury permissions
- `store.ui().treasuryTab`
Should update when:
- funds change
- a credit line is added or updated
- the user changes treasury tab
Should not update when:
- member roster changes unrelated to treasury display
- fleet changes
### `ModalLayer`
Depends on:
- `store.ui().modal`
Should update when:
- a modal opens
- a modal closes
- modal payload changes
Should not update when unrelated domain state changes.
## Mutation Examples
### Add member
```js
addMember(member) {
this.roster.update((state) => ({
...state,
members: [...state.members, member],
}));
}
```
Only subscribers to `roster` rerender.
### Update credit line
```js
upsertCreditLine(nextLine) {
this.treasury.update((state) => {
const exists = state.creditLines.some((line) => line.uid === nextLine.uid);
return {
...state,
creditLines: exists
? state.creditLines.map((line) =>
line.uid === nextLine.uid ? nextLine : line
)
: [...state.creditLines, nextLine],
};
});
}
```
Only subscribers to `treasury` rerender.
## Bridge Response Strategy
For responsive UIs, each server-backed action should define:
- request event
- success patch event
- failure notice event or payload
Example credit line flow:
1. JS sends `org::credit::request`
2. SQF/server validates and persists
3. SQF sends:
- `org::member::creditUpdated` on success
- `org::credit::failure` on failure
4. JS store applies a targeted patch
5. `TreasuryCard` and any dependent member row update
This is preferable to sending a full `org::sync` after every action.
## Shared Components
The common addon should provide plain, themeable primitives only.
Recommended first set:
- app shell
- title bar
- navbar
- modal
- notice/toast
- stat card
- empty state
- action row
- form field
- spinner
- error banner
These should accept data and callbacks, not own business logic.
## Styling Model
Use layered CSS:
1. common tokens
2. common primitives
3. feature theme
4. feature view styles
The common layer should define:
- spacing scale
- type scale
- colors
- elevation/shadows
- radius
- focus states
- motion durations
Feature UIs should override tokens rather than rewriting primitive CSS.
## Asset Loading
The loader should support:
- `A3API.RequestFile`
- `A3API.RequestTexture`
- local `fetch()` fallback for browser testing
Recommended change:
- stop loading many small scripts individually in production
- build one common runtime file and one feature app file
- keep source files split in repo, but ship bundled outputs into `_site`
That reduces browser startup cost and simplifies ordering problems.
## Error Handling
The framework should standardize:
- bridge unavailable errors
- malformed payload errors
- timeout handling for requests that expect responses
- visible in-UI notices for recoverable failures
- `console.error` plus `diag_log` friendly payloads
Recommended bridge helper:
```js
bridge.request("store::checkout::request", payload, {
pending: "Submitting order...",
timeoutMs: 15000,
onTimeout() {
notices.error("The checkout request timed out.");
}
});
```
## Migration Plan
### Phase 1
Extract common pieces without changing app behavior:
- shared JS host adapter
- shared JS bridge
- shared signal/runtime
- shared SQF bridge base class
### Phase 2
Migrate `org` and `store` first because they already use the same custom runtime pattern.
### Phase 3
Migrate `bank`, `garage`, and `notifications`.
### Phase 4
Migrate `actor`, which may need more event-heavy interaction handling.
### Phase 5
Bundle all `_site` apps into production-ready outputs.
## First Implementation Targets
The first concrete files to build should be:
1. `arma/client/addons/common/ui/src/host.js`
2. `arma/client/addons/common/ui/src/runtime.js`
3. `arma/client/addons/common/ui/src/bridge.js`
4. `arma/client/addons/common/ui/src/app.js`
5. `arma/client/addons/common/functions/fnc_initWebUIBridge.sqf`
Those five pieces establish the core contract. After that, `org` and `store` can be migrated with low risk.
## Non-Goals
At least initially, this framework should not try to provide:
- client-side routing between pages
- SSR or pre-rendering
- JSX compilation
- TypeScript-only tooling assumptions
- a giant component system
- generalized diffing for every possible DOM edge case
This should stay focused on Arma in-browser application UIs.
## Recommended Direction
Use `forge_client_common` as the host for a small custom reactive framework, not as a dumping ground for copied app utilities.
The correct abstraction boundary is:
- `common` owns the browser platform
- each addon owns the application
That gives one UI system across the repo without forcing all screens into one monolithic app.

View File

@ -1 +1,2 @@
PREP(initWebUIBridge);

View File

@ -0,0 +1,209 @@
#include "..\script_component.hpp"
/*
* File: fnc_initWebUIBridge.sqf
* Author: IDSolutions
* Date: 2026-03-13
* Last Update: 2026-03-13
* Public: No
*
* Description:
* Initializes the shared web UI bridge and screen declarations used by
* CT_WEBBROWSER feature bridges.
*
* Arguments:
* None
*
* Return Value:
* Web UI bridge declarations [HASHMAP]
*
* Example:
* call forge_client_common_fnc_initWebUIBridge
*/
if !(isNil QGVAR(WebUIScreenDeclaration) || { isNil QGVAR(WebUIBridgeDeclaration) }) exitWith {
createHashMapFromArray [
["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)],
["screenDeclaration", GVAR(WebUIScreenDeclaration)]
]
};
#pragma hemtt ignore_variables ["_self"]
GVAR(WebUIScreenDeclaration) = compileFinal createHashMapFromArray [
["#type", "IWebUIScreen"],
["#create", compileFinal {
params [["_control", controlNull, [controlNull]]];
_self set ["control", _control];
_self set ["readyState", false];
_self set ["pendingEvents", []];
}],
["dispose", compileFinal {
_self set ["control", controlNull];
_self set ["readyState", false];
_self set ["pendingEvents", []];
true
}],
["getControl", compileFinal {
_self getOrDefault ["control", controlNull]
}],
["consumePendingEvents", compileFinal {
private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]);
_self set ["pendingEvents", []];
_pendingEvents
}],
["isReady", compileFinal {
_self getOrDefault ["readyState", false]
}],
["markReady", compileFinal {
params [["_isReady", true, [false]]];
_self set ["readyState", _isReady];
_isReady
}],
["queueEvent", compileFinal {
params [["_payload", createHashMap, [createHashMap]]];
private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]);
_pendingEvents pushBack _payload;
_self set ["pendingEvents", _pendingEvents];
count _pendingEvents
}],
["setControl", compileFinal {
params [["_control", controlNull, [controlNull]]];
_self set ["control", _control];
_control
}],
["#delete", compileFinal {
_self call ["dispose", []];
}]
];
GVAR(WebUIBridgeDeclaration) = compileFinal createHashMapFromArray [
["#type", "IWebUIBridge"],
["#create", compileFinal {
_self set ["screen", createHashMapObject [GVAR(WebUIScreenDeclaration)]];
}],
["deliverPayload", compileFinal {
params [["_control", controlNull, [controlNull]], ["_payload", createHashMap, [createHashMap]]];
if (isNull _control) exitWith { false };
private _json = toJSON _payload;
_control ctrlWebBrowserAction ["ExecJS", format ["ForgeBridge.receive(%1)", _json]];
true
}],
["execJS", compileFinal {
params [["_control", controlNull, [controlNull]], ["_statement", "", [""]]];
if (isNull _control || { _statement isEqualTo "" }) exitWith { false };
_control ctrlWebBrowserAction ["ExecJS", _statement];
true
}],
["flushPendingEvents", compileFinal {
private _screen = _self call ["getScreen", []];
private _control = _self call ["getActiveBrowserControl", []];
if (isNull _control) exitWith { 0 };
private _pendingEvents = _screen call ["consumePendingEvents", []];
{
_self call ["deliverPayload", [_control, _x]];
} forEach _pendingEvents;
count _pendingEvents
}],
["getActiveBrowserControl", compileFinal {
private _screen = _self call ["getScreen", []];
_screen call ["getControl", []]
}],
["getScreen", compileFinal {
private _hasScreen = "screen" in _self;
private _screen = if (_hasScreen) then {
_self get "screen"
} else {
createHashMap
};
if (!_hasScreen) then {
_screen = createHashMapObject [GVAR(WebUIScreenDeclaration)];
_self set ["screen", _screen];
};
_screen
}],
["handleClose", compileFinal {
private _screen = _self call ["getScreen", []];
_screen call ["dispose", []]
}],
["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", []];
true
}],
["queueEvent", compileFinal {
params [["_payload", createHashMap, [createHashMap]]];
private _screen = _self call ["getScreen", []];
_screen call ["queueEvent", [_payload]]
}],
["sendEvent", compileFinal {
params [
["_event", "", [""]],
["_data", createHashMap, [createHashMap]],
["_control", controlNull, [controlNull]]
];
if (_event isEqualTo "") exitWith { false };
private _payload = createHashMapFromArray [
["event", _event],
["data", _data]
];
private _screen = _self call ["getScreen", []];
private _targetControl = _control;
if (isNull _targetControl) then {
_targetControl = _self call ["getActiveBrowserControl", []];
};
if (isNull _targetControl) exitWith {
_self call ["queueEvent", [_payload]];
false
};
_screen call ["setControl", [_targetControl]];
if !(_screen call ["isReady", []]) exitWith {
_self call ["queueEvent", [_payload]];
false
};
_self call ["deliverPayload", [_targetControl, _payload]]
}],
["setActiveBrowserControl", compileFinal {
params [["_control", controlNull, [controlNull]]];
private _screen = _self call ["getScreen", []];
_screen call ["setControl", [_control]]
}],
["#delete", compileFinal {
_self call ["handleClose", []];
}]
];
createHashMapFromArray [
["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)],
["screenDeclaration", GVAR(WebUIScreenDeclaration)]
]

View File

@ -0,0 +1 @@
!function(e){const o=e.ForgeSiteLoader=e.ForgeSiteLoader||{};function t(e){return"string"==typeof e&&e.startsWith("forge\\")}function r({addonRoot:e,browserBase:o,assetPath:r}){if("undefined"!=typeof A3API&&A3API&&"function"==typeof A3API.RequestFile){const o=t(r)?r:e+String(r||"").replace(/\//g,"\\");return A3API.RequestFile(o)}const n=t(r)?r:function(e,o){return`${String(e||"./").replace(/\\/g,"/")}${String(o||"").replace(/\\/g,"/")}`}(o,r);return fetch(n).then(e=>{if(!e.ok)throw new Error(`Failed to load ${n}`);return e.text()})}function n(e){const o=document.createElement("style");o.textContent=e,document.head.appendChild(o)}function a(e){const o=document.createElement("script");o.text=e,document.head.appendChild(o)}async function i(e){const o=e&&e.addonName?e.addonName:"";if(!o)throw new Error("ForgeSiteLoader requires a config.addonName value.");const t=function(e){return`forge\\forge_client\\addons\\${e}\\ui\\_site\\`}(o),i=e.browserAddonBase||"./",s=e.browserCommonBase||"../../../common/ui/_site/",c=Array.isArray(e.styles)?e.styles:[],d=Array.isArray(e.commonScripts)?e.commonScripts:[],f=Array.isArray(e.scripts)?e.scripts:[];(await Promise.all(c.map(e=>r({addonRoot:t,browserBase:i,assetPath:e})))).forEach(n);(await Promise.all(d.map(e=>r({addonRoot:"forge\\forge_client\\addons\\common\\ui\\_site\\",browserBase:s,assetPath:e})))).forEach(a);(await Promise.all(f.map(e=>r({addonRoot:t,browserBase:i,assetPath:e})))).forEach(a)}o.boot=i,e.ForgeSiteConfig&&!1!==e.ForgeSiteConfig.autoBoot&&i(e.ForgeSiteConfig).catch(o=>{const t=e.ForgeSiteConfig.logLabel||e.ForgeSiteConfig.addonName||"Forge UI";console.error(`[${t}] Failed to load site assets.`,o)})}(window);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,60 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
function resolveRoot(root) {
if (!root) {
return null;
}
if (typeof root === "string") {
return document.querySelector(root);
}
return root instanceof Element ? root : null;
}
function createApp(options = {}) {
const name = options.name || "app";
const root = options.root || "#app";
const setup =
typeof options.setup === "function" ? options.setup : () => {};
let started = false;
function start() {
if (started) {
return;
}
started = true;
const boot = () => {
const rootNode = resolveRoot(root);
if (!rootNode) {
console.error(
`[ForgeWebUI] Root node not found for ${name}.`,
);
return;
}
setup({
name,
root: rootNode,
runtime: ForgeWebUI,
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, {
once: true,
});
return;
}
boot();
}
return { start };
}
ForgeWebUI.createApp = createApp;
})(window);

View File

@ -0,0 +1,128 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
function createBridge(options = {}) {
const host =
options.host && typeof options.host === "object"
? options.host
: ForgeWebUI.createHost();
const globalName = options.globalName || "ForgeBridge";
const readyEvent = options.readyEvent || "ui::ready";
const closeEvent = options.closeEvent || "ui::close";
const listeners = new Map();
function getListeners(eventName) {
if (!listeners.has(eventName)) {
listeners.set(eventName, new Set());
}
return listeners.get(eventName);
}
function emit(eventName, payload) {
const eventListeners = listeners.get(eventName);
if (!eventListeners || eventListeners.size === 0) {
return;
}
eventListeners.forEach((listener) => {
try {
listener(payload);
} catch (error) {
console.error(
`[ForgeWebUI] Bridge listener failed for ${eventName}.`,
error,
);
}
});
}
function receive(eventOrPayload, data = {}) {
const eventName =
typeof eventOrPayload === "object" && eventOrPayload !== null
? String(eventOrPayload.event || "")
: String(eventOrPayload || "");
const payload =
typeof eventOrPayload === "object" && eventOrPayload !== null
? eventOrPayload.data || {}
: data;
if (!eventName) {
return false;
}
emit(eventName, payload);
emit("*", { data: payload, event: eventName });
return true;
}
function receiveMany(events) {
if (!Array.isArray(events)) {
return false;
}
events.forEach((payload) => receive(payload));
return true;
}
const globalBridge = {
ping() {
return true;
},
receive,
receiveMany,
reset() {
listeners.clear();
return true;
},
};
const api = {
close(data = {}) {
return host.send(closeEvent, data);
},
emit,
host,
installCompatibility(name) {
if (name) {
global[name] = globalBridge;
}
return api;
},
off(eventName, listener) {
const eventListeners = listeners.get(eventName);
if (!eventListeners) {
return false;
}
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(eventName);
}
return true;
},
on(eventName, listener) {
getListeners(eventName).add(listener);
return () => api.off(eventName, listener);
},
ready(data = { loaded: true }) {
return host.send(readyEvent, data);
},
receive,
receiveMany,
request(eventName, payload = {}) {
return host.send(eventName, payload);
},
send(eventName, payload = {}) {
return host.send(eventName, payload);
},
};
global[globalName] = globalBridge;
return api;
}
ForgeWebUI.createBridge = createBridge;
})(window);

View File

@ -0,0 +1,68 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
function createHost() {
const api = global.A3API;
return {
isArma: Boolean(api),
close(event = "ui::close", data = {}) {
return this.send(event, data);
},
exec(statement) {
if (
!api ||
typeof api.Exec !== "function" ||
typeof statement !== "string"
) {
return false;
}
api.Exec(statement);
return true;
},
requestFile(path) {
if (api && typeof api.RequestFile === "function") {
return api.RequestFile(path);
}
return fetch(path).then((response) => {
if (!response.ok) {
throw new Error(`Failed to load ${path}`);
}
return response.text();
});
},
requestTexture(path, size = 512) {
if (api && typeof api.RequestTexture === "function") {
return api.RequestTexture(path, size);
}
return Promise.reject(
new Error("Texture requests are unavailable outside Arma."),
);
},
send(event, data = {}) {
if (
!api ||
typeof api.SendAlert !== "function" ||
typeof event !== "string" ||
event === ""
) {
return false;
}
api.SendAlert(
JSON.stringify({
event,
data,
}),
);
return true;
},
};
}
ForgeWebUI.createHost = createHost;
})(window);

View File

@ -0,0 +1,5 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
ForgeWebUI.version = "0.1.0";
})(window);

View File

@ -0,0 +1,428 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
const SVG_NS = "http://www.w3.org/2000/svg";
const SVG_TAGS = new Set([
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"g",
"defs",
"use",
"text",
"tspan",
"clipPath",
"mask",
]);
const injectedStyles = new Set();
const scheduledObservers = new Set();
let activeObserver = null;
let batchDepth = 0;
let flushQueued = false;
function queueFlush() {
if (flushQueued || batchDepth > 0) {
return;
}
flushQueued = true;
queueMicrotask(() => {
flushQueued = false;
flushObservers();
});
}
function flushObservers() {
while (scheduledObservers.size > 0) {
const queue = Array.from(scheduledObservers);
scheduledObservers.clear();
queue.forEach((observer) => runObserver(observer));
}
}
function cleanupObserver(observer) {
if (typeof observer.cleanup === "function") {
try {
observer.cleanup();
} catch (error) {
console.error("[ForgeWebUI] Observer cleanup failed.", error);
}
}
observer.cleanup = null;
observer.dependencies.forEach((dependency) => {
dependency.delete(observer);
});
observer.dependencies.clear();
}
function runObserver(observer) {
if (!observer || observer.disposed) {
return;
}
cleanupObserver(observer);
const previousObserver = activeObserver;
activeObserver = observer;
try {
const cleanup = observer.fn();
if (typeof cleanup === "function") {
observer.cleanup = cleanup;
}
} catch (error) {
console.error("[ForgeWebUI] Observer execution failed.", error);
} finally {
activeObserver = previousObserver;
}
}
function scheduleObserver(observer) {
if (!observer || observer.disposed) {
return;
}
scheduledObservers.add(observer);
queueFlush();
}
function trackDependency(dependency) {
if (!activeObserver) {
return;
}
dependency.add(activeObserver);
activeObserver.dependencies.add(dependency);
}
function createSignalValue(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
trackDependency(subscribers);
return value;
}
read.peek = () => value;
read.set = (nextValue) => {
const resolvedValue =
typeof nextValue === "function" ? nextValue(value) : nextValue;
if (Object.is(resolvedValue, value)) {
return value;
}
value = resolvedValue;
subscribers.forEach((observer) => scheduleObserver(observer));
return value;
};
read.update = (updater) => read.set(updater);
read.subscribe = (listener) =>
effect(() => {
listener(read());
});
return read;
}
function createSignal(initialValue) {
const signal = createSignalValue(initialValue);
return [signal, signal.set];
}
function computed(factory) {
const valueSignal = createSignalValue(undefined);
let initialized = false;
effect(() => {
const nextValue = factory();
if (!initialized || !Object.is(nextValue, valueSignal.peek())) {
initialized = true;
valueSignal.set(nextValue);
}
});
return valueSignal;
}
function effect(fn) {
const observer = {
cleanup: null,
dependencies: new Set(),
disposed: false,
fn,
};
observer.dispose = () => {
if (observer.disposed) {
return;
}
observer.disposed = true;
scheduledObservers.delete(observer);
cleanupObserver(observer);
};
runObserver(observer);
return observer.dispose;
}
function batch(fn) {
batchDepth += 1;
try {
return fn();
} finally {
batchDepth = Math.max(0, batchDepth - 1);
if (batchDepth === 0) {
flushObservers();
}
}
}
function appendChild(node, child) {
if (child === null || child === undefined || child === false) {
return;
}
if (Array.isArray(child)) {
child.forEach((entry) => appendChild(node, entry));
return;
}
if (
typeof child === "string" ||
typeof child === "number" ||
typeof child === "bigint"
) {
node.appendChild(document.createTextNode(String(child)));
return;
}
if (child instanceof Node) {
node.appendChild(child);
}
}
function fragment(...children) {
const node = document.createDocumentFragment();
children.forEach((child) => appendChild(node, child));
return node;
}
function text(value) {
return document.createTextNode(String(value ?? ""));
}
function applyProp(node, key, value, isSvg) {
if (key === "key") {
return;
}
if (key === "ref" && typeof value === "function") {
value(node);
return;
}
if (key === "className") {
if (isSvg) {
node.setAttribute("class", value || "");
} else {
node.className = value || "";
}
return;
}
if (key === "style" && value && typeof value === "object") {
Object.assign(node.style, value);
return;
}
if (key === "dataset" && value && typeof value === "object") {
Object.entries(value).forEach(([name, datasetValue]) => {
node.dataset[name] = datasetValue;
});
return;
}
if (key.startsWith("on") && typeof value === "function") {
node.addEventListener(key.slice(2).toLowerCase(), value);
return;
}
if (key === "value" && "value" in node) {
node.value = value ?? "";
return;
}
if (key === "checked" && "checked" in node) {
node.checked = Boolean(value);
return;
}
if (key === "selected" && "selected" in node) {
node.selected = Boolean(value);
return;
}
if (typeof value === "boolean") {
if (value) {
node.setAttribute(key, "");
} else {
node.removeAttribute(key);
}
return;
}
if (value === null || value === undefined) {
node.removeAttribute(key);
return;
}
node.setAttribute(key, value);
}
function h(tag, props = {}, ...children) {
const isSvg = SVG_TAGS.has(tag);
const node = isSvg
? document.createElementNS(SVG_NS, tag)
: document.createElement(tag);
if (props && typeof props === "object") {
Object.entries(props).forEach(([key, value]) => {
applyProp(node, key, value, isSvg);
});
}
children.forEach((child) => appendChild(node, child));
return node;
}
function normalizeNode(node) {
if (node === null || node === undefined || node === false) {
return document.createDocumentFragment();
}
if (Array.isArray(node)) {
return fragment(...node);
}
if (
typeof node === "string" ||
typeof node === "number" ||
typeof node === "bigint"
) {
return text(node);
}
if (node instanceof Node) {
return node;
}
return document.createDocumentFragment();
}
function captureScrollState(container) {
return Array.from(
container.querySelectorAll("[data-preserve-scroll-id]"),
).map((node) => ({
id: node.getAttribute("data-preserve-scroll-id"),
scrollLeft: node.scrollLeft,
scrollTop: node.scrollTop,
}));
}
function restoreScrollState(container, scrollState) {
if (!Array.isArray(scrollState) || scrollState.length === 0) {
return;
}
scrollState.forEach((entry) => {
if (!entry || !entry.id) {
return;
}
const target = container.querySelector(
`[data-preserve-scroll-id="${entry.id}"]`,
);
if (!target) {
return;
}
target.scrollTop = Number(entry.scrollTop || 0);
target.scrollLeft = Number(entry.scrollLeft || 0);
});
}
function mount(container, render, options = {}) {
const preserveScroll = options.preserveScroll !== false;
const dispose = effect(() => {
const scrollState = preserveScroll
? captureScrollState(container)
: [];
const nextNode = normalizeNode(render());
container.replaceChildren(nextNode);
if (preserveScroll && scrollState.length > 0) {
requestAnimationFrame(() => {
restoreScrollState(container, scrollState);
});
}
});
return {
container,
dispose,
rerender() {
container.replaceChildren(normalizeNode(render()));
},
};
}
function render(component, container, options = {}) {
return mount(container, component, options);
}
function unmount(mountHandle) {
if (!mountHandle || typeof mountHandle.dispose !== "function") {
return;
}
mountHandle.dispose();
}
function ensureScopedStyle(id, cssText) {
if (!id || !cssText || injectedStyles.has(id)) {
return;
}
const style = document.createElement("style");
style.setAttribute("data-ui-style", id);
style.textContent = cssText;
document.head.appendChild(style);
injectedStyles.add(id);
}
ForgeWebUI.batch = batch;
ForgeWebUI.computed = computed;
ForgeWebUI.createSignal = createSignal;
ForgeWebUI.effect = effect;
ForgeWebUI.ensureScopedStyle = ensureScopedStyle;
ForgeWebUI.fragment = fragment;
ForgeWebUI.h = h;
ForgeWebUI.mount = mount;
ForgeWebUI.render = render;
ForgeWebUI.signal = createSignalValue;
ForgeWebUI.text = text;
ForgeWebUI.unmount = unmount;
})(window);

View File

@ -0,0 +1,126 @@
(function (global) {
const ForgeSiteLoader = (global.ForgeSiteLoader =
global.ForgeSiteLoader || {});
const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\";
const defaultBrowserCommonBase = "../../../common/ui/_site/";
function isArmaAvailable() {
return (
typeof A3API !== "undefined" &&
A3API &&
typeof A3API.RequestFile === "function"
);
}
function isAbsoluteAddonPath(path) {
return typeof path === "string" && path.startsWith("forge\\");
}
function normalizeAddonRoot(addonName) {
return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`;
}
function normalizeBrowserPath(basePath, assetPath) {
const normalizedBase = String(basePath || "./").replace(/\\/g, "/");
const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/");
return `${normalizedBase}${normalizedAssetPath}`;
}
function requestText({ addonRoot, browserBase, assetPath }) {
if (isArmaAvailable()) {
const resolvedPath = isAbsoluteAddonPath(assetPath)
? assetPath
: addonRoot + String(assetPath || "").replace(/\//g, "\\");
return A3API.RequestFile(resolvedPath);
}
const browserPath = isAbsoluteAddonPath(assetPath)
? assetPath
: normalizeBrowserPath(browserBase, assetPath);
return fetch(browserPath).then((response) => {
if (!response.ok) {
throw new Error(`Failed to load ${browserPath}`);
}
return response.text();
});
}
function appendStyle(cssText) {
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
}
function appendScript(jsText) {
const script = document.createElement("script");
script.text = jsText;
document.head.appendChild(script);
}
async function boot(config) {
const addonName = config && config.addonName ? config.addonName : "";
if (!addonName) {
throw new Error(
"ForgeSiteLoader requires a config.addonName value.",
);
}
const addonRoot = normalizeAddonRoot(addonName);
const browserAddonBase = config.browserAddonBase || "./";
const browserCommonBase =
config.browserCommonBase || defaultBrowserCommonBase;
const styles = Array.isArray(config.styles) ? config.styles : [];
const commonScripts = Array.isArray(config.commonScripts)
? config.commonScripts
: [];
const scripts = Array.isArray(config.scripts) ? config.scripts : [];
const styleChunks = await Promise.all(
styles.map((assetPath) =>
requestText({
addonRoot,
browserBase: browserAddonBase,
assetPath,
}),
),
);
styleChunks.forEach(appendStyle);
const commonScriptChunks = await Promise.all(
commonScripts.map((assetPath) =>
requestText({
addonRoot: commonAddonRoot,
browserBase: browserCommonBase,
assetPath,
}),
),
);
commonScriptChunks.forEach(appendScript);
const scriptChunks = await Promise.all(
scripts.map((assetPath) =>
requestText({
addonRoot,
browserBase: browserAddonBase,
assetPath,
}),
),
);
scriptChunks.forEach(appendScript);
}
ForgeSiteLoader.boot = boot;
if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) {
boot(global.ForgeSiteConfig).catch((error) => {
const logLabel =
global.ForgeSiteConfig.logLabel ||
global.ForgeSiteConfig.addonName ||
"Forge UI";
console.error(`[${logLabel}] Failed to load site assets.`, error);
});
}
})(window);

View File

@ -0,0 +1,238 @@
(function (global) {
const ForgeWebUI = global.ForgeWebUI;
const SharedUI = (global.SharedUI = global.SharedUI || {});
const { h, ensureScopedStyle } = ForgeWebUI;
const titleBarCss = `
.ui-window-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: var(--ui-titlebar-min-height, 3.5rem);
padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem);
background: var(
--ui-titlebar-bg,
linear-gradient(180deg, #12325b 0%, #0d2643 100%)
);
color: var(--ui-titlebar-text, #f4f8fd);
border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1));
box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18));
position: var(--ui-titlebar-position, relative);
top: var(--ui-titlebar-top, auto);
z-index: var(--ui-titlebar-z-index, 5);
flex-shrink: 0;
}
.ui-window-titlebar-brand {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.1rem;
min-width: 0;
}
.ui-window-titlebar-kicker {
font-size: 0.64rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72));
}
.ui-window-titlebar-title {
font-size: var(--ui-titlebar-title-size, 1rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: var(--ui-titlebar-title-spacing, -0.03em);
color: inherit;
}
.ui-window-titlebar-controls {
display: flex;
align-items: center;
gap: 0.12rem;
}
.ui-window-control-btn {
min-width: 2rem;
height: 2rem;
margin: 0;
padding: 0;
border-radius: 0.38rem;
border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16));
background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04));
color: var(--ui-window-control-text, rgb(237 244 251 / 0.88));
line-height: 1;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: none;
transform: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ui-window-control-btn + .ui-window-control-btn {
margin-left: 0;
}
.ui-window-control-btn:hover {
background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04));
box-shadow: none;
transform: none;
}
.ui-window-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-window-control-btn.is-close {
cursor: pointer;
opacity: 1;
background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1));
}
.ui-window-control-btn.is-close:hover {
background: var(
--ui-window-control-close-hover-bg,
rgb(185 67 67 / 0.9)
);
border-color: var(
--ui-window-control-close-hover-border,
rgb(255 222 222 / 0.45)
);
}
.ui-window-control-icon {
width: 0.78rem;
height: 0.78rem;
stroke: currentColor;
fill: none;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
@media (max-width: 960px) {
.ui-window-titlebar {
flex-direction: column;
align-items: flex-start;
}
.ui-window-titlebar-controls {
width: 100%;
justify-content: flex-end;
}
}
`;
SharedUI.componentFns = SharedUI.componentFns || {};
function WindowControlIcon({ type }) {
if (type === "minimize") {
return h(
"svg",
{
className: "ui-window-control-icon",
viewBox: "0 0 16 16",
"aria-hidden": "true",
},
h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }),
);
}
if (type === "maximize") {
return h(
"svg",
{
className: "ui-window-control-icon",
viewBox: "0 0 16 16",
"aria-hidden": "true",
},
h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }),
);
}
return h(
"svg",
{
className: "ui-window-control-icon",
viewBox: "0 0 16 16",
"aria-hidden": "true",
},
h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }),
h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }),
);
}
SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({
kicker = "",
title = "",
onClose = null,
closeLabel = "Close interface",
minimizeLabel = "Minimize unavailable",
maximizeLabel = "Maximize unavailable",
} = {}) {
ensureScopedStyle("shared-window-titlebar", titleBarCss);
return h(
"div",
{ className: "ui-window-titlebar" },
h(
"div",
{ className: "ui-window-titlebar-brand" },
kicker
? h(
"span",
{ className: "ui-window-titlebar-kicker" },
kicker,
)
: null,
h("span", { className: "ui-window-titlebar-title" }, title),
),
h(
"div",
{ className: "ui-window-titlebar-controls" },
h(
"button",
{
type: "button",
className: "ui-window-control-btn",
disabled: true,
title: minimizeLabel,
"aria-label": minimizeLabel,
},
WindowControlIcon({ type: "minimize" }),
),
h(
"button",
{
type: "button",
className: "ui-window-control-btn",
disabled: true,
title: maximizeLabel,
"aria-label": maximizeLabel,
},
WindowControlIcon({ type: "maximize" }),
),
h(
"button",
{
type: "button",
className: "ui-window-control-btn is-close",
title: "Close",
"aria-label": closeLabel,
onClick:
typeof onClose === "function" ? onClose : () => {},
},
WindowControlIcon({ type: "close" }),
),
),
);
};
})(window);

View File

@ -1,4 +1,3 @@
forge_client_garage
===================
# forge_client_garage
Description for this addon

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,7 +1,10 @@
#include "script_component.hpp"
if (isNil QGVAR(GarageClass)) then { [] call FUNC(initGarageClass); };
if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); };
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), {
GVAR(GarageClass) call ["init", []];
@ -10,29 +13,43 @@ if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); };
[QGVAR(responseInitGarage), {
params [["_data", createHashMap, [createHashMap]]];
GVAR(GarageClass) call ["sync", [_data, true]];
GVAR(GarageClass) call ["sync", [_data]];
if !(isNil QGVAR(GarageUIBridge)) then {
GVAR(GarageUIBridge) call ["refreshGarage", []];
};
}] call CFUNC(addEventHandler);
[QGVAR(responseSyncGarage), {
params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]];
params [["_data", createHashMap, [createHashMap, []]]];
GVAR(GarageClass) call ["sync", [_data, _jip]];
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), {
GVAR(VGarageClass) call ["init", []];
GVAR(VGClass) call ["init", []];
}] call CFUNC(addEventHandler);
[QGVAR(responseInitVG), {
params [["_data", createHashMap, [createHashMap]]];
GVAR(VGarageClass) call ["sync", [_data, true]];
GVAR(VGClass) call ["sync", [_data]];
}] call CFUNC(addEventHandler);
[QGVAR(responseSyncVG), {
params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]];
params [["_data", createHashMap, [createHashMap, []]]];
GVAR(VGarageClass) call ["sync", [_data, _jip]];
GVAR(VGClass) call ["sync", [_data]];
}] call CFUNC(addEventHandler);
[{

View File

@ -1,3 +1,2 @@
#include "script_component.hpp"
#include "XEH_PREP.hpp"

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

@ -1,17 +1,66 @@
#include "..\script_component.hpp"
/*
* File: fnc_handleUIEvents.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2026-01-30
* Public: No
*
* Description:
* Handles the UI events.
*
* Arguments:
* None
* 0: [CONTROL] - The control that triggered the event
* 1: [BOOL] - Whether the event is from a confirm dialog
* 2: [STRING] - The message containing the event data
*
* Return Value:
* None
* UI events handled [BOOL]
*
* Example:
* [] call forge_client_garage_fnc_handleUIEvents;
*
* Public: No
* 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,31 +1,36 @@
#include "..\script_component.hpp"
/*
* File: fnc_initClass.sqf
* Author: IDSolutions
* Initializes the garage class.
* Date: 2025-12-17
* Last Update: 2026-02-13
* Public: No
*
* Description:
* Initializes the Garage class for managing player vehicles.
* Provides methods for syncing, saving, and applying vehicles to the player's garage.
*
* Arguments:
* None
*
* Return Value:
* None
* Garage class object [HASHMAP OBJECT]
*
* Example:
* [] call forge_client_garage_fnc_initGarageClass;
*
* Public: No
* call forge_client_garage_fnc_initClass
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(GarageClass) = createHashMapObject [[
["#type", "IGarageClass"],
["#create", {
GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [
["#type", "GarageBaseClass"],
["#create", compileFinal {
_self set ["uid", (getPlayerUID player)];
_self set ["garage", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
}],
["init", {
["init", compileFinal {
private _uid = _self get "uid";
private _garage = _self get "garage";
@ -34,40 +39,34 @@ GVAR(GarageClass) = createHashMapObject [[
systemChat format ["Garage loaded for %1", (name player)];
diag_log "[FORGE:Client:Garage] Garage Class Initialized!";
}],
["save", {
params [["_sync", false, [false]]];
["save", compileFinal {
private _uid = _self get "uid";
[SRPC(garage,requestSaveGarage), [_uid, _sync]] call CFUNC(serverEvent);
[SRPC(garage,requestSaveGarage), [_uid]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", {
params [["_data", createHashMap, [createHashMap]], ["_sync", false, [false]]];
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _garage = _self get "garage";
private _isLoaded = _self get "isLoaded";
private _garage = createHashMap;
if (_data isEqualTo createHashMap) exitWith {
diag_log "[FORGE:Client:Garage] Empty data received for sync, skipping.";
};
{
_garage set [_x, _y];
} forEach _data;
{ _garage set [_x, _y]; } forEach _data;
_self set ["garage", _garage];
if !(_isLoaded) then { _self set ["isLoaded", true]; };
diag_log "[FORGE:Client:Garage] Sync completed";
}],
["get", {
["getGarageState", compileFinal {
_self getOrDefault ["garage", createHashMap]
}],
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _garage = _self get "garage";
_garage getOrDefault [_key, _default];
}]
]];
];
SETVAR(player,FORGE_GarageClass,GVAR(GarageClass));
GVAR(GarageClass) = createHashMapObject [GVAR(GarageBaseClass)];
GVAR(GarageClass)

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

@ -1,46 +1,38 @@
#include "..\script_component.hpp"
/*
* File: fnc_initVGarageClass.sqf
* File: fnc_initVGClass.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2025-12-19
* Last Update: 2026-02-13
* Public: No
*
* Description:
* Initializes the Virtual Garage class for managing player garage unlocks.
* Provides methods for syncing, saving, and applying virtual items to BIS Garage.
* Initializes the Virtual Garage class for managing player garage unlocks.
* Provides methods for syncing, saving, and applying virtual items to BIS Garage.
*
* Parameter(s):
* None
* Arguments:
* None
*
* Returns:
* vGarage class object [HASHMAP OBJECT]
* Return Value:
* vGarage class object [HASHMAP OBJECT]
*
* Example(s):
* [] call forge_client_locker_fnc_initVGClass;
* Example:
* call forge_client_garage_fnc_initVGClass;
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(VGarageClass) = createHashMapObject [[
["#type", "IVGarageClass"],
["#create", {
GVAR(VGBaseClass) = compileFinal createHashMapFromArray [
["#type", "VGBaseClass"],
["#create", compileFinal {
GVAR(isPreLoaded) = false;
_self set ["uid", (getPlayerUID player)];
_self set ["vGarage", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
private _vGarage = createHashMap;
_vGarage set ["cars", ["B_Quadbike_01_F"]];
_vGarage set ["armor", []];
_vGarage set ["helis", []];
_vGarage set ["planes", []];
_vGarage set ["naval", []];
_vGarage set ["other", []];
_self set ["vGarage", _vGarage];
}],
["init", {
["init", compileFinal {
private _uid = _self get "uid";
private _vGarage = _self get "vGarage";
@ -49,35 +41,29 @@ GVAR(VGarageClass) = createHashMapObject [[
systemChat format ["VGarage loaded for %1", (name player)];
diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!";
}],
["save", {
params [["_sync", false, [false]]];
["save", compileFinal {
private _uid = _self get "uid";
[SRPC(garage,requestSaveVG), [_uid, _sync]] call CFUNC(serverEvent);
[SRPC(garage,requestSaveVG), [_uid]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _vGarage = _self get "vGarage";
private _isLoaded = _self get "isLoaded";
if (_data isEqualTo createHashMap) exitWith { diag_log "[FORGE:Client:VGarage] Empty data received for sync, skipping."; };
{
_vGarage set [_x, _y];
if (_jip) then {
switch (_x) do {
case "cars": { _self call ["apply", ["cars"]]; };
case "armor": { _self call ["apply", ["armor"]]; };
case "helis": { _self call ["apply", ["helis"]]; };
case "planes": { _self call ["apply", ["planes"]]; };
case "naval": { _self call ["apply", ["naval"]]; };
case "other": { _self call ["apply", ["other"]]; };
default {};
};
switch (_x) do {
case "cars": { _self call ["apply", ["cars"]]; };
case "armor": { _self call ["apply", ["armor"]]; };
case "helis": { _self call ["apply", ["helis"]]; };
case "planes": { _self call ["apply", ["planes"]]; };
case "naval": { _self call ["apply", ["naval"]]; };
case "other": { _self call ["apply", ["other"]]; };
default {};
};
} forEach _data;
@ -86,31 +72,33 @@ GVAR(VGarageClass) = createHashMapObject [[
if !(_isLoaded) then { _self set ["isLoaded", true]; };
diag_log "[FORGE:Client:VGarage] Sync completed";
}],
["get", {
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _vGarage = _self get "vGarage";
_vGarage getOrDefault [_key, _default];
}],
["apply", {
["apply", compileFinal {
params [["_key", "", [""]]];
private _vehicles = _self call ["get", [_key, []]];
private _array = switch (_key) do {
case "cars": { GVAR(Cars) };
case "armor": { GVAR(Armor) };
case "helis": { GVAR(Helis) };
case "planes": { GVAR(Planes) };
case "naval": { GVAR(Naval) };
case "other": { GVAR(Other) };
default { [] };
};
private _appliedVehicles = [];
{
_array append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]];
_appliedVehicles append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]];
} forEach _vehicles;
}]
]];
SETVAR(player,FORGE_VGarageClass,GVAR(VGarageClass));
GVAR(VGarageClass)
switch (_key) do {
case "cars": { GVAR(Cars) = _appliedVehicles; };
case "armor": { GVAR(Armor) = _appliedVehicles; };
case "helis": { GVAR(Helis) = _appliedVehicles; };
case "planes": { GVAR(Planes) = _appliedVehicles; };
case "naval": { GVAR(Naval) = _appliedVehicles; };
case "other": { GVAR(Other) = _appliedVehicles; };
default {};
};
}]
];
GVAR(VGClass) = createHashMapObject [GVAR(VGBaseClass)];
GVAR(VGClass)

View File

@ -1,17 +1,38 @@
#include "..\script_component.hpp"
/*
* File: fnc_openUI.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2026-01-30
* Public: No
*
* Description:
* Opens the garage UI.
*
* Arguments:
* None
*
* Return Value:
* None
* UI opened [BOOL]
*
* Example:
* [] call forge_client_garage_fnc_openUI;
*
* Public: No
* 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,23 +1,23 @@
#include "..\script_component.hpp"
/*
* File: fnc_initVG.sqf
* File: fnc_openVG.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2025-12-17
* Last Update: 2026-01-30
* Public: No
*
* Description:
* No description added yet.
* Opens the Virtual Garage.
*
* Parameter(s):
* N/A
* Arguments:
* None
*
* Returns:
* Something [BOOL]
* Return Value:
* None
*
* Example(s):
* [parameter] call forge_x_component_fnc_myFunction
* Example:
* call forge_client_garage_fnc_openVG
*/
private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData);
@ -27,57 +27,69 @@ private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") ca
} count _locations;
BIS_fnc_garage_center = createVehicle ["Land_HelipadEmpty_F", FORGE_VehSpawnPos, [], 0, "NONE"];
[missionNamespace, "garageOpened", {
params ["_display", "_toggleSpace"];
missionNamespace setVariable ["BIS_fnc_garage_data", [
GVAR(Cars),
GVAR(Armor),
GVAR(Helis),
GVAR(Planes),
GVAR(Naval),
GVAR(Other)
]];
{
lbClear (_display displayCtrl (960 + _forEachIndex));
} forEach BIS_fnc_garage_data;
["ListAdd", [_display]] call BFUNC(garage);
}] call BFUNC(addScriptedEventHandler);
BIS_fnc_garage_centerType = getText (configFile >> "CfgVehicles" >> "B_Quadbike_01_F" >> "model");
if !(GVAR(isPreLoaded)) then {
[missionNamespace, "garageOpened", {
params ["_display", "_toggleSpace"];
missionNamespace setVariable ["BIS_fnc_garage_data", [
GVAR(Cars),
GVAR(Armor),
GVAR(Helis),
GVAR(Planes),
GVAR(Naval),
GVAR(Other)
]];
{
lbClear (_display displayCtrl (960 + _forEachIndex));
} forEach BIS_fnc_garage_data;
_display displayAddEventHandler ["KeyDown", "_this select 3"];
{ (_display displayCtrl _x) ctrlShow false } forEach [44151, 44150, 44146, 44147, 44148, 44149, 44346, 44347, 978];
["ListAdd", [_display]] call BFUNC(garage);
}] call BFUNC(addScriptedEventHandler);
[missionNamespace, "garageClosed", {
private _nearestObjects = BIS_fnc_garage_center nearEntities [["Car","Tank","Air","Ship"], 15];
if (!isNil "_nearestObjects") then {
private _obj = _nearestObjects select 0;
private _veh = typeOf _obj;
private _textures = getObjectTextures _obj;
private _animationNames = animationNames _obj;
{ deleteVehicle _x } forEach _nearestObjects;
private _createVehicle = createVehicle [_veh, FORGE_VehSpawnPos, [], 0, "CAN_COLLIDE"];
if (_textures isNotEqualTo []) then {
private _count = 0;
{
_createVehicle setObjectTextureGlobal [_count, _x];
_count = _count + 1;
} forEach _textures;
};
if (_animationNames isNotEqualTo []) then {
private _animationPhase = [];
for "_i" from 0 to count _animationNames -1 do {
_animationPhase pushBack [_animationNames select _i, _obj animationPhase (_animationNames select _i)];
{ _createVehicle animate _x; } forEach _animationPhase;
};
};
};
}] call BFUNC(addScriptedEventHandler);
GVAR(isPreLoaded) = true;
};
private _nearVehicles = FORGE_VehSpawnPos nearEntities [["Car", "Tank", "Air", "Ship"], 5];
if (_nearVehicles isNotEqualTo []) exitWith {
private _params = ["warning", "Virtual Garage", "Vehicle spawn position is blocked. Please move the vehicle before accessing the garage.", 3000];
EGVAR(notifications,NotificationClass) call ["create", _params];
};
["Open", true] call BFUNC(garage);
[missionNamespace, "garageClosed", {
private _nearestObjects = BIS_fnc_garage_center nearEntities [["Car","Tank","Air","Ship"], 15];
if (!isNil "_nearestObjects") then {
private _obj = _nearestObjects select 0;
private _veh = typeOf _obj;
private _textures = getObjectTextures _obj;
private _animationNames = animationNames _obj;
{ deleteVehicle _x } forEach _nearestObjects;
private _createVehicle = createVehicle [_veh, FORGE_VehSpawnPos, [], 0, "CAN_COLLIDE"];
if (_textures isNotEqualTo []) then {
private _count = 0;
{
_createVehicle setObjectTextureGlobal [_count, _x];
_count = _count + 1;
} forEach _textures;
};
if (_animationNames isNotEqualTo []) then {
private _animationPhase = [];
for "_i" from 0 to count _animationNames -1 do {
_animationPhase pushBack [_animationNames select _i, _obj animationPhase (_animationNames select _i)];
{ _createVehicle animate _x; } forEach _animationPhase;
};
};
};
}] call BFUNC(addScriptedEventHandler);

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,205 +1 @@
<!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
-->
<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);
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
</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>
</body>
</html>
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>FORGE Vehicle Garage</title><script>window.ForgeSiteConfig={addonName:"garage",logLabel:"Garage UI",styles:["garage-ui.css"],commonScripts:["forge-webui.js"],scripts:["garage-ui.js"]},function(){const e="../../../common/ui/_site/forge-site-loader.js";("undefined"!=typeof A3API&&A3API&&"function"==typeof A3API.RequestFile?A3API.RequestFile("forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js"):fetch(e).then(o=>{if(!o.ok)throw new Error("Failed to load "+e);return o.text()})).then(function(e){const o=document.createElement("script");o.text=e,document.head.appendChild(o)}).catch(e=>{console.error("[Garage UI] Failed to load Forge site loader.",e)})}()</script></head><body><div id="app"></div></body></html>

View File

@ -1,317 +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;

Some files were not shown because too many files have changed in this diff Show More