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:
commit
7a8ca6b237
@ -1,12 +1,17 @@
|
|||||||
# Contributing Setup & Guidelines
|
# Contributing Setup & Guidelines
|
||||||
|
|
||||||
## Setting up the Development Environment
|
## Setting up the Development Environment
|
||||||
|
|
||||||
### 1. Clone the repository from GitHub
|
### 1. Clone the repository from GitHub
|
||||||
|
|
||||||
### 2. Install HEMTT
|
### 2. Install HEMTT
|
||||||
|
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Guidelines
|
## 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).
|
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||||
|
|||||||
@ -1,25 +1,31 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a bug report to help us improve
|
about: Create a bug report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/bug
|
labels: kind/bug
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the bug
|
## Describe the bug
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## To reproduce
|
## To reproduce
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected behavior
|
## Expected behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a feature to be added
|
about: Suggest a feature to be added
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/feature-request
|
labels: kind/feature-request
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the feature that you would like
|
## Describe the feature that you would like
|
||||||
|
|
||||||
A clear and concise description of the feature you'd want.
|
A clear and concise description of the feature you'd want.
|
||||||
|
|
||||||
## Possible alternatives
|
## Possible alternatives
|
||||||
|
|
||||||
Possible alternatives to your suggestion.
|
Possible alternatives to your suggestion.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the feature here.
|
Add any other context about the feature here.
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
**When merged this pull request will:**
|
**When merged this pull request will:**
|
||||||
|
|
||||||
- Describe what this pull request will do
|
- Describe what this pull request will do
|
||||||
- Each change in a separate line
|
- Each change in a separate line
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
|
|
||||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
- [ ] 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.
|
- [ ] [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}`.
|
- [ ] 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 that need to be addressed -->
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- [ ] Issue
|
- [ ] Issue
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -21,6 +21,12 @@ target/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Arma
|
||||||
|
arma/ui/map-viewer/
|
||||||
|
|||||||
@ -20,7 +20,7 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
|
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]
|
SessionMgmt[Session Management<br/>- Token Generation<br/>- UID Resolution<br/>- Player State]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -105,15 +105,18 @@ sequenceDiagram
|
|||||||
## 🚀 **Performance Characteristics**
|
## 🚀 **Performance Characteristics**
|
||||||
|
|
||||||
### **Access Times**
|
### **Access Times**
|
||||||
|
|
||||||
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
|
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
|
||||||
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
|
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
|
||||||
- **Client Cache**: `< 0.1ms` (Local object access)
|
- **Client Cache**: `< 0.1ms` (Local object access)
|
||||||
|
|
||||||
### **Cache Hit Ratios**
|
### **Cache Hit Ratios**
|
||||||
|
|
||||||
- **Hot Cache**: `~95%` (Active players)
|
- **Hot Cache**: `~95%` (Active players)
|
||||||
- **Cold Storage**: `~5%` (New connections, cache misses)
|
- **Cold Storage**: `~5%` (New connections, cache misses)
|
||||||
|
|
||||||
### **Memory Usage**
|
### **Memory Usage**
|
||||||
|
|
||||||
- **Server Registry**: `~1KB per active player`
|
- **Server Registry**: `~1KB per active player`
|
||||||
- **Client Cache**: `~500B per player object`
|
- **Client Cache**: `~500B per player object`
|
||||||
- **Redis**: `~2KB per player (persistent)`
|
- **Redis**: `~2KB per player (persistent)`
|
||||||
@ -125,7 +128,7 @@ flowchart TD
|
|||||||
subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;]
|
subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;]
|
||||||
Conn[Player Connection] --> Token[Session Token Generation<br/>#40;Generated on server#41;]
|
Conn[Player Connection] --> Token[Session Token Generation<br/>#40;Generated on server#41;]
|
||||||
Token --> UID[UID Resolution<br/>#40;Steam UID mapping#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;]
|
State --> Access[Data Access Authorized<br/>#40;Authorized via session#41;]
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|||||||
12
LICENSE.md
12
LICENSE.md
@ -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:
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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
|
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.
|
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.
|
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.
|
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
|||||||
### Bohemia Interactive Notices
|
### Bohemia Interactive Notices
|
||||||
|
|
||||||
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
||||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||||
|
|||||||
53
README.md
53
README.md
@ -33,6 +33,7 @@ graph TD
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Communication Flow**:
|
**Communication Flow**:
|
||||||
|
|
||||||
- **Clients** → Use events (`CBA_Events`) to communicate with server
|
- **Clients** → Use events (`CBA_Events`) to communicate with server
|
||||||
- **Server** → Calls Rust extension via `callExtension`
|
- **Server** → Calls Rust extension via `callExtension`
|
||||||
- **Extension** → Manages Redis connection pool and data operations
|
- **Extension** → Manages Redis connection pool and data operations
|
||||||
@ -87,12 +88,14 @@ forge/
|
|||||||
|
|
||||||
1. Clone the repository from Gitea
|
1. Clone the repository from Gitea
|
||||||
2. Install HEMTT
|
2. Install HEMTT
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Coding Guidelines
|
### 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).
|
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
|
### Building the Extension
|
||||||
@ -143,14 +146,18 @@ private _update = createHashMapFromArray [["bank", 1500]];
|
|||||||
## Core Modules
|
## Core Modules
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
Defines strict data structures with built-in validation:
|
Defines strict data structures with built-in validation:
|
||||||
|
|
||||||
- `Actor`: Player data (stats, inventory, position)
|
- `Actor`: Player data (stats, inventory, position)
|
||||||
- `Org`: Organization/clan data (members, roles, metadata)
|
- `Org`: Organization/clan data (members, roles, metadata)
|
||||||
|
|
||||||
[Documentation](lib/models/README.md)
|
[Documentation](lib/models/README.md)
|
||||||
|
|
||||||
### Repositories
|
### Repositories
|
||||||
|
|
||||||
Manages data persistence with Redis:
|
Manages data persistence with Redis:
|
||||||
|
|
||||||
- Hash-based storage for structured data
|
- Hash-based storage for structured data
|
||||||
- Set-based storage for collections
|
- Set-based storage for collections
|
||||||
- Generic over Redis client implementations
|
- Generic over Redis client implementations
|
||||||
@ -158,7 +165,9 @@ Manages data persistence with Redis:
|
|||||||
[Documentation](lib/repositories/README.md)
|
[Documentation](lib/repositories/README.md)
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
|
|
||||||
Implements business logic and orchestration:
|
Implements business logic and orchestration:
|
||||||
|
|
||||||
- Get-or-create patterns
|
- Get-or-create patterns
|
||||||
- Data validation and transformation
|
- Data validation and transformation
|
||||||
- Complex workflows
|
- Complex workflows
|
||||||
@ -166,7 +175,9 @@ Implements business logic and orchestration:
|
|||||||
[Documentation](lib/services/README.md)
|
[Documentation](lib/services/README.md)
|
||||||
|
|
||||||
### Extension
|
### Extension
|
||||||
|
|
||||||
Arma 3 interface layer:
|
Arma 3 interface layer:
|
||||||
|
|
||||||
- Command routing and parsing
|
- Command routing and parsing
|
||||||
- Session management
|
- Session management
|
||||||
- Error handling and logging
|
- Error handling and logging
|
||||||
@ -174,7 +185,9 @@ Arma 3 interface layer:
|
|||||||
[Documentation](arma/server/extension/README.md)
|
[Documentation](arma/server/extension/README.md)
|
||||||
|
|
||||||
### Client Mod
|
### Client Mod
|
||||||
|
|
||||||
Client-side SQF addon that provides:
|
Client-side SQF addon that provides:
|
||||||
|
|
||||||
- **UI Components**: Player interfaces for inventory, organizations, banking
|
- **UI Components**: Player interfaces for inventory, organizations, banking
|
||||||
- **Event Handlers**: CBA event listeners for server communication
|
- **Event Handlers**: CBA event listeners for server communication
|
||||||
- **Optimistic Caching**: Local data caching for instant UI updates
|
- **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
|
- **Input Validation**: Client-side validation before server requests
|
||||||
|
|
||||||
The client mod communicates with the server using **CBA Events**, ensuring:
|
The client mod communicates with the server using **CBA Events**, ensuring:
|
||||||
|
|
||||||
- No direct extension calls from clients (security)
|
- No direct extension calls from clients (security)
|
||||||
- Event-driven architecture for scalability
|
- Event-driven architecture for scalability
|
||||||
- Automatic state synchronization across all clients
|
- Automatic state synchronization across all clients
|
||||||
@ -190,28 +204,32 @@ The client mod communicates with the server using **CBA Events**, ensuring:
|
|||||||
## Available Commands
|
## Available Commands
|
||||||
|
|
||||||
### Actor Commands
|
### Actor Commands
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
| Command | Description |
|
||||||
| `actor:get` | Retrieve actor data by UID |
|
| -------------- | -------------------------- |
|
||||||
| `actor:create` | Create a new actor |
|
| `actor:get` | Retrieve actor data by UID |
|
||||||
| `actor:update` | Update actor fields |
|
| `actor:create` | Create a new actor |
|
||||||
| `actor:exists` | Check if actor exists |
|
| `actor:update` | Update actor fields |
|
||||||
| `actor:delete` | Delete actor data |
|
| `actor:exists` | Check if actor exists |
|
||||||
|
| `actor:delete` | Delete actor data |
|
||||||
|
|
||||||
### Organization Commands
|
### Organization Commands
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
| Command | Description |
|
||||||
| `org:get` | Retrieve organization data |
|
| ------------------- | ------------------------------- |
|
||||||
| `org:create` | Create a new organization |
|
| `org:get` | Retrieve organization data |
|
||||||
| `org:update` | Update organization fields |
|
| `org:create` | Create a new organization |
|
||||||
| `org:exists` | Check if organization exists |
|
| `org:update` | Update organization fields |
|
||||||
| `org:delete` | Delete organization |
|
| `org:exists` | Check if organization exists |
|
||||||
| `org:add_member` | Add member to organization |
|
| `org:delete` | Delete organization |
|
||||||
|
| `org:add_member` | Add member to organization |
|
||||||
| `org:remove_member` | Remove member from organization |
|
| `org:remove_member` | Remove member from organization |
|
||||||
| `org:get_members` | Get all organization members |
|
| `org:get_members` | Get all organization members |
|
||||||
|
|
||||||
### Redis Operations
|
### Redis Operations
|
||||||
|
|
||||||
Direct Redis operations for advanced use cases:
|
Direct Redis operations for advanced use cases:
|
||||||
|
|
||||||
- **Common**: Key-value operations (set, get, incr, decr, del)
|
- **Common**: Key-value operations (set, get, incr, decr, del)
|
||||||
- **Hash**: Structured data (hset, hget, hgetall, hdel)
|
- **Hash**: Structured data (hset, hget, hgetall, hdel)
|
||||||
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
|
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
|
||||||
@ -264,6 +282,7 @@ if (_response find "Error:" == 0) then {
|
|||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Logs are automatically created in `@forge_server/logs/`:
|
Logs are automatically created in `@forge_server/logs/`:
|
||||||
|
|
||||||
- `actor.log` - Actor operations
|
- `actor.log` - Actor operations
|
||||||
- `org.log` - Organization operations
|
- `org.log` - Organization operations
|
||||||
- `redis.log` - Redis connection and operations
|
- `redis.log` - Redis connection and operations
|
||||||
|
|||||||
5
arma/client/.github/CONTRIBUTING.md
vendored
5
arma/client/.github/CONTRIBUTING.md
vendored
@ -1,12 +1,17 @@
|
|||||||
# Contributing Setup & Guidelines
|
# Contributing Setup & Guidelines
|
||||||
|
|
||||||
## Setting up the Development Environment
|
## Setting up the Development Environment
|
||||||
|
|
||||||
### 1. Clone the repository from GitHub
|
### 1. Clone the repository from GitHub
|
||||||
|
|
||||||
### 2. Install HEMTT
|
### 2. Install HEMTT
|
||||||
|
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Guidelines
|
## 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).
|
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||||
|
|||||||
@ -1,25 +1,31 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a bug report to help us improve
|
about: Create a bug report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/bug
|
labels: kind/bug
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the bug
|
## Describe the bug
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## To reproduce
|
## To reproduce
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected behavior
|
## Expected behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a feature to be added
|
about: Suggest a feature to be added
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/feature-request
|
labels: kind/feature-request
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the feature that you would like
|
## Describe the feature that you would like
|
||||||
|
|
||||||
A clear and concise description of the feature you'd want.
|
A clear and concise description of the feature you'd want.
|
||||||
|
|
||||||
## Possible alternatives
|
## Possible alternatives
|
||||||
|
|
||||||
Possible alternatives to your suggestion.
|
Possible alternatives to your suggestion.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the feature here.
|
Add any other context about the feature here.
|
||||||
|
|||||||
4
arma/client/.github/PULL_REQUEST_TEMPLATE.md
vendored
4
arma/client/.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,12 +1,16 @@
|
|||||||
**When merged this pull request will:**
|
**When merged this pull request will:**
|
||||||
|
|
||||||
- Describe what this pull request will do
|
- Describe what this pull request will do
|
||||||
- Each change in a separate line
|
- Each change in a separate line
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
|
|
||||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
- [ ] 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.
|
- [ ] [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}`.
|
- [ ] 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 that need to be addressed -->
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- [ ] Issue
|
- [ ] Issue
|
||||||
|
|||||||
24
arma/client/.github/workflows/check.yml
vendored
24
arma/client/.github/workflows/check.yml
vendored
@ -12,17 +12,17 @@ jobs:
|
|||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the source code
|
- name: Checkout the source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Validate Config
|
- name: Validate Config
|
||||||
run: python tools/config_style_checker.py
|
run: python tools/config_style_checker.py
|
||||||
- name: Check for BOM
|
- name: Check for BOM
|
||||||
uses: arma-actions/bom-check@master
|
uses: arma-actions/bom-check@master
|
||||||
with:
|
with:
|
||||||
path: "addons"
|
path: "addons"
|
||||||
|
|
||||||
- name: Setup HEMTT
|
- name: Setup HEMTT
|
||||||
uses: arma-actions/hemtt@v1
|
uses: arma-actions/hemtt@v1
|
||||||
- name: Run HEMTT check
|
- name: Run HEMTT check
|
||||||
run: hemtt check --pedantic
|
run: hemtt check --pedantic
|
||||||
|
|||||||
1
arma/client/.gitignore
vendored
1
arma/client/.gitignore
vendored
@ -3,6 +3,7 @@ hemtt.exe
|
|||||||
.hemtt/missions/~*
|
.hemtt/missions/~*
|
||||||
.hemttout/
|
.hemttout/
|
||||||
releases/
|
releases/
|
||||||
|
.hemttprivatekey
|
||||||
|
|
||||||
# Textures
|
# Textures
|
||||||
Exports/
|
Exports/
|
||||||
|
|||||||
@ -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:
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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
|
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.
|
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.
|
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.
|
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
|||||||
### Bohemia Interactive Notices
|
### Bohemia Interactive Notices
|
||||||
|
|
||||||
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
||||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||||
|
|||||||
@ -18,10 +18,13 @@
|
|||||||
The project is entirely **open-source** and any contributions are welcome.
|
The project is entirely **open-source** and any contributions are welcome.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
- Feature
|
- Feature
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Forge Client is licensed under [APL-SA](./LICENSE.md).
|
Forge Client is licensed under [APL-SA](./LICENSE.md).
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_actor
|
# forge_client_actor
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -23,7 +23,7 @@ player addEventHandler ["Respawn", {
|
|||||||
[SRPC(economy,onRespawn), [_unit, _corpse, _uid]] call CFUNC(serverEvent);
|
[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), {
|
[QGVAR(initActor), {
|
||||||
GVAR(ActorClass) call ["init", []];
|
GVAR(ActorClass) call ["init", []];
|
||||||
|
|||||||
@ -1,19 +1,25 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_handleUIEvents.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2026-01-28
|
||||||
|
* Last Update: 2026-02-17
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Handles the UI events.
|
* Handles the UI events.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* 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:
|
* Return Value:
|
||||||
* None
|
* UI events handled [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_actor_fnc_handleUIEvents;
|
* call forge_client_actor_fnc_handleUIEvents;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
params ["_control", "_isConfirmDialog", "_message"];
|
params ["_control", "_isConfirmDialog", "_message"];
|
||||||
@ -21,27 +27,25 @@ params ["_control", "_isConfirmDialog", "_message"];
|
|||||||
private _alert = fromJSON _message;
|
private _alert = fromJSON _message;
|
||||||
private _event = _alert get "event";
|
private _event = _alert get "event";
|
||||||
private _data = _alert get "data";
|
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];
|
diag_log format ["[FORGE:Client:Actor] Handling UI event: %1 with data: %2", _event, _data];
|
||||||
|
|
||||||
switch (_event) do {
|
switch (_event) do {
|
||||||
case "actor::get::actions": { GVAR(ActorClass) call ["getNearbyActions", [_control]]; };
|
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::atm": { [true] spawn EFUNC(bank,openUI); };
|
||||||
case "actor::open::bank": { [] 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::device": { hint "Device interaction is not yet implemented."; };
|
||||||
case "actor::open::garage": { hint "Garage interaction is not yet implemented."; }; // TODO: Implement garage interaction
|
case "actor::open::garage": { [] spawn EFUNC(garage,openUI); };
|
||||||
case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); };
|
case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); };
|
||||||
case "actor::open::org": { [] spawn EFUNC(org,openUI); };
|
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": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) };
|
||||||
case "actor::open::vlocker": { ["Open", [false, FORGE_Locker_Box, player]] spawn BFUNC(arsenal) };
|
case "actor::open::phone": { hint "Phone interaction is not yet implemented."; };
|
||||||
// case "actor::open::phone": { [] spawn EFUNC(phone,openUI) };
|
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." };
|
||||||
case "actor::open::phone": { hint "Phone interaction is not yet implemented."; }; // TODO: Implement phone interaction
|
case "actor::open::store": { [] spawn EFUNC(store,openUI); };
|
||||||
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
|
|
||||||
default { hint format ["Unhandled UI event: %1", _event]; };
|
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;
|
true;
|
||||||
|
|||||||
@ -1,56 +1,43 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_initActorClass.sqf
|
||||||
* Author: IDSolutions
|
* 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:
|
* Arguments:
|
||||||
* None
|
* None
|
||||||
*
|
*
|
||||||
* Return Value:
|
* Return Value:
|
||||||
* None
|
* Actor class object [HASHMAP OBJECT]
|
||||||
*
|
*
|
||||||
* Examples:
|
* Example:
|
||||||
* [] call forge_client_actor_fnc_initActorClass
|
* call forge_client_actor_fnc_initActorClass
|
||||||
*
|
|
||||||
* Public: Yes
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#pragma hemtt ignore_variables ["_self"]
|
#pragma hemtt ignore_variables ["_self"]
|
||||||
GVAR(ActorClass) = createHashMapObject [[
|
GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [
|
||||||
["#type", "IActorClass"],
|
["#type", "ActorBaseClass"],
|
||||||
["#create", {
|
["#create", compileFinal {
|
||||||
_self set ["uid", getPlayerUID player];
|
_self set ["uid", getPlayerUID player];
|
||||||
_self set ["actor", createHashMap];
|
_self set ["actor", createHashMap];
|
||||||
_self set ["isLoaded", false];
|
_self set ["isLoaded", false];
|
||||||
_self set ["lastSave", time];
|
_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 _uid = _self get "uid";
|
||||||
private _actor = _self get "actor";
|
[SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent);
|
||||||
|
|
||||||
[SRPC(actor,requestInitActor), [_uid, _actor]] call CFUNC(serverEvent);
|
|
||||||
|
|
||||||
systemChat format ["Actor loaded for %1", (name player)];
|
systemChat format ["Actor loaded for %1", (name player)];
|
||||||
diag_log "[FORGE:Client:Actor] Actor Class Initialized!";
|
diag_log "[FORGE:Client:Actor] Actor Class Initialized!";
|
||||||
}],
|
}],
|
||||||
["save", {
|
["save", compileFinal {
|
||||||
params [["_sync", false, [false]]];
|
params [["_sync", false, [false]]];
|
||||||
|
|
||||||
private _uid = _self get "uid";
|
private _uid = _self get "uid";
|
||||||
@ -58,16 +45,12 @@ GVAR(ActorClass) = createHashMapObject [[
|
|||||||
|
|
||||||
_self set ["lastSave", time];
|
_self set ["lastSave", time];
|
||||||
}],
|
}],
|
||||||
["sync", {
|
["sync", compileFinal {
|
||||||
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
|
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
|
||||||
|
|
||||||
private _actor = _self get "actor";
|
private _actor = _self get "actor";
|
||||||
private _isLoaded = _self get "isLoaded";
|
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];
|
_actor set [_x, _y];
|
||||||
|
|
||||||
@ -89,13 +72,13 @@ GVAR(ActorClass) = createHashMapObject [[
|
|||||||
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
||||||
diag_log "[FORGE:Client:Actor] Sync completed";
|
diag_log "[FORGE:Client:Actor] Sync completed";
|
||||||
}],
|
}],
|
||||||
["get", {
|
["get", compileFinal {
|
||||||
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
||||||
|
|
||||||
private _actor = _self get "actor";
|
private _actor = _self get "actor";
|
||||||
_actor getOrDefault [_key, _default];
|
_actor getOrDefault [_key, _default];
|
||||||
}],
|
}],
|
||||||
["applyPosition", {
|
["applyPosition", compileFinal {
|
||||||
private _position = _self call ["get", ["position", [0, 0, 0]]];
|
private _position = _self call ["get", ["position", [0, 0, 0]]];
|
||||||
|
|
||||||
if (GVAR(enableLoc)) then {
|
if (GVAR(enableLoc)) then {
|
||||||
@ -112,33 +95,30 @@ GVAR(ActorClass) = createHashMapObject [[
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}],
|
}],
|
||||||
["applyDirection", {
|
["applyDirection", compileFinal {
|
||||||
private _direction = _self call ["get", ["direction", 0]];
|
private _direction = _self call ["get", ["direction", 0]];
|
||||||
|
|
||||||
if (GVAR(enableLoc)) then { player setDir _direction; };
|
if (GVAR(enableLoc)) then { player setDir _direction; };
|
||||||
}],
|
}],
|
||||||
["applyStance", {
|
["applyStance", compileFinal {
|
||||||
private _stance = _self call ["get", ["stance", "STAND"]];
|
private _stance = _self call ["get", ["stance", "STAND"]];
|
||||||
|
|
||||||
if (GVAR(enableLoc)) then { player playAction _stance; };
|
if (GVAR(enableLoc)) then { player playAction _stance; };
|
||||||
}],
|
}],
|
||||||
["applyRank", {
|
["applyRank", compileFinal {
|
||||||
private _rank = _self call ["get", ["rank", "PRIVATE"]];
|
private _rank = _self call ["get", ["rank", "PRIVATE"]];
|
||||||
|
|
||||||
player setUnitRank _rank;
|
player setUnitRank _rank;
|
||||||
}],
|
}],
|
||||||
["applyLoadout", {
|
["applyLoadout", compileFinal {
|
||||||
private _loadout = _self call ["get", ["loadout", []]];
|
private _loadout = _self call ["get", ["loadout", []]];
|
||||||
|
|
||||||
if (GVAR(enableGear) && count _loadout > 0) then { player setUnitLoadout _loadout; };
|
if (GVAR(enableGear) && count _loadout > 0) then { player setUnitLoadout _loadout; };
|
||||||
}],
|
}],
|
||||||
["getNearbyActions", {
|
["getNearbyActions", compileFinal {
|
||||||
params [["_control", controlNull, [controlNull]]];
|
params [["_control", controlNull, [controlNull]]];
|
||||||
|
|
||||||
private _nearbyActions = [];
|
private _nearbyActions = [];
|
||||||
|
|
||||||
{
|
{
|
||||||
private _storeType = _x getVariable ["storeType", ""];
|
private _storeType = _x getVariable ["storeType", ""];
|
||||||
|
private _isAtm = _x getVariable ["isAtm", false];
|
||||||
private _isBank = _x getVariable ["isBank", false];
|
private _isBank = _x getVariable ["isBank", false];
|
||||||
private _isGarage = _x getVariable ["isGarage", false];
|
private _isGarage = _x getVariable ["isGarage", false];
|
||||||
private _isLocker = _x getVariable ["isLocker", false];
|
private _isLocker = _x getVariable ["isLocker", false];
|
||||||
@ -147,18 +127,18 @@ GVAR(ActorClass) = createHashMapObject [[
|
|||||||
private _isPlayer = _x isKindOf "Man" && isPlayer _x;
|
private _isPlayer = _x isKindOf "Man" && isPlayer _x;
|
||||||
|
|
||||||
if (_storeType isNotEqualTo "") then { _nearbyActions pushBack ["store", _storeType]; };
|
if (_storeType isNotEqualTo "") then { _nearbyActions pushBack ["store", _storeType]; };
|
||||||
|
if (_isAtm) then { _nearbyActions pushBack ["atm", true]; };
|
||||||
if (_isBank) then { _nearbyActions pushBack ["bank", 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 (_isLocker && GVAR(enableVA)) then { _nearbyActions pushBack ["va", true]; };
|
||||||
if (_isGarage) then { _nearbyActions pushBack ["garage", _garageType]; };
|
if (_isGarage) then { _nearbyActions pushBack ["garage", _garageType]; };
|
||||||
if (_isGarage && GVAR(enableVG)) then { _nearbyActions pushBack ["vg", true]; };
|
if (_isGarage && GVAR(enableVG)) then { _nearbyActions pushBack ["vg", true]; };
|
||||||
if (_deviceType isNotEqualTo "") then { _nearbyActions pushBack ["device", _deviceType]; };
|
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);
|
} forEach (player nearObjects 5);
|
||||||
|
|
||||||
_control ctrlWebBrowserAction ["ExecJS", format ["updateAvailableActions(%1)", (toJSON _nearbyActions)]];
|
_control ctrlWebBrowserAction ["ExecJS", format ["updateAvailableActions(%1)", (toJSON _nearbyActions)]];
|
||||||
}]
|
}]
|
||||||
]];
|
];
|
||||||
|
|
||||||
SETVAR(player,FORGE_ActorClass,GVAR(ActorClass));
|
GVAR(ActorClass) = createHashMapObject [GVAR(ActorBaseClass)];
|
||||||
GVAR(ActorClass)
|
GVAR(ActorClass)
|
||||||
|
|||||||
@ -1,23 +1,27 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_openUI.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2026-01-28
|
||||||
|
* Last Update: 2026-01-30
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Opens the player interaction interface.
|
* Opens the player interaction interface.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* Arguments:
|
||||||
* None
|
* None
|
||||||
*
|
*
|
||||||
* Return Value:
|
* Return Value:
|
||||||
* None
|
* UI opened [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_actor_fnc_openUI;
|
* call forge_client_actor_fnc_openUI;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private _display = (findDisplay 46) createDisplay "RscActorMenu";
|
private _display = createDialog ["RscActorMenu", true];
|
||||||
private _ctrl = (_display displayCtrl 1001);
|
private _ctrl = _display displayCtrl 1001;
|
||||||
|
|
||||||
_ctrl ctrlAddEventHandler ["JSDialog", {
|
_ctrl ctrlAddEventHandler ["JSDialog", {
|
||||||
params ["_control", "_isConfirmDialog", "_message"];
|
params ["_control", "_isConfirmDialog", "_message"];
|
||||||
|
|||||||
@ -1,70 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Interaction Menu</title>
|
||||||
<title>Interaction Menu</title>
|
<!-- <link rel="stylesheet" href="style.css"> -->
|
||||||
<link rel="stylesheet" href="style.css" />
|
<!--
|
||||||
<!--
|
|
||||||
Dynamic Resource Loading
|
Dynamic Resource Loading
|
||||||
The following script loads CSS and JavaScript files dynamically using the A3API
|
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
|
This approach is used instead of static HTML imports to work with Arma 3's file system
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
|
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
|
||||||
),
|
),
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
||||||
),
|
),
|
||||||
]).then(([css, js]) => {
|
]).then(([css, js]) => {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = css;
|
style.textContent = css;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.text = js;
|
script.text = js;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</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>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- <script src="script.js"></script> -->
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -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
|
// #region ACTIONS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
// Action Types
|
|
||||||
const ActionTypes = {
|
const ActionTypes = {
|
||||||
SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS",
|
SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS",
|
||||||
SET_MENU_ITEMS: "SET_MENU_ITEMS",
|
SET_MENU_ITEMS: "SET_MENU_ITEMS",
|
||||||
@ -15,7 +69,6 @@ const ActionTypes = {
|
|||||||
CLEAR_ACTIONS: "CLEAR_ACTIONS",
|
CLEAR_ACTIONS: "CLEAR_ACTIONS",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Action Creators
|
|
||||||
const actions = {
|
const actions = {
|
||||||
setAvailableActions: (actionTypes) => ({
|
setAvailableActions: (actionTypes) => ({
|
||||||
type: ActionTypes.SET_AVAILABLE_ACTIONS,
|
type: ActionTypes.SET_AVAILABLE_ACTIONS,
|
||||||
@ -47,84 +100,91 @@ const actions = {
|
|||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
const baseMenuItems = [
|
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",
|
id: "phone",
|
||||||
title: "Personal Phone",
|
title: "Phone",
|
||||||
description: "Access and manage your personal phone",
|
description: "Access and manage your personal phone",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::phone",
|
action: "actor::open::phone",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "org",
|
id: "org",
|
||||||
title: "Organization Dashboard",
|
title: "Organization",
|
||||||
description: "View and manage your organization data",
|
description: "View and manage your organization data",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::org",
|
action: "actor::open::org",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "store",
|
||||||
|
title: "Store",
|
||||||
|
description: "Browse and purchase items from the store",
|
||||||
|
action: "actor::open::store",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const actionDefinitions = {
|
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: {
|
device: {
|
||||||
id: "device",
|
id: "device",
|
||||||
title: "Device Interaction",
|
title: "Device",
|
||||||
description: "Manage devices and settings",
|
description: "Manage devices and settings",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::device",
|
action: "actor::open::device",
|
||||||
},
|
},
|
||||||
garage: {
|
garage: {
|
||||||
id: "garage",
|
id: "garage",
|
||||||
title: "Vehicle Garage",
|
title: "Garage",
|
||||||
description: "Access and manage your vehicle collection",
|
description: "Access and manage your vehicle collection",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::garage",
|
action: "actor::open::garage",
|
||||||
},
|
},
|
||||||
locker: {
|
|
||||||
id: "locker",
|
|
||||||
title: "Locker",
|
|
||||||
description: "Access your personal locker for storage",
|
|
||||||
icon: "",
|
|
||||||
action: "actor::open::locker",
|
|
||||||
},
|
|
||||||
player: {
|
player: {
|
||||||
id: "player",
|
id: "player",
|
||||||
title: "Player Interaction",
|
title: "Player",
|
||||||
description: "Interact with player-specific actions",
|
description: "Interact with player-specific actions",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::iplayer",
|
action: "actor::open::iplayer",
|
||||||
},
|
},
|
||||||
store: {
|
store: {
|
||||||
id: "store",
|
id: "store",
|
||||||
title: "Store",
|
title: "Store",
|
||||||
description: "Browse and purchase items from the store",
|
description: "Browse and purchase items from the store",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::store",
|
action: "actor::open::store",
|
||||||
},
|
},
|
||||||
va: {
|
va: {
|
||||||
id: "va",
|
id: "va",
|
||||||
title: "Virtual Arsenal",
|
title: "Arsenal",
|
||||||
description: "Access your virtual arsenal",
|
description: "Access your virtual arsenal",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::vlocker",
|
action: "actor::open::vlocker",
|
||||||
},
|
},
|
||||||
vg: {
|
vg: {
|
||||||
id: "vg",
|
id: "vg",
|
||||||
title: "Virtual Garage",
|
title: "V. Garage",
|
||||||
description: "Access your virtual garage",
|
description: "Access your virtual garage",
|
||||||
icon: "",
|
|
||||||
action: "actor::open::vgarage",
|
action: "actor::open::vgarage",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -141,7 +201,6 @@ function actorReducer(state = initialState, action) {
|
|||||||
case ActionTypes.SET_AVAILABLE_ACTIONS:
|
case ActionTypes.SET_AVAILABLE_ACTIONS:
|
||||||
const newMenuItems = [...state.baseMenuItems];
|
const newMenuItems = [...state.baseMenuItems];
|
||||||
|
|
||||||
// Process available actions
|
|
||||||
const actionArray = Array.isArray(action.payload)
|
const actionArray = Array.isArray(action.payload)
|
||||||
? action.payload
|
? action.payload
|
||||||
: [];
|
: [];
|
||||||
@ -225,6 +284,7 @@ class Store {
|
|||||||
console.log("Dispatching action:", action);
|
console.log("Dispatching action:", action);
|
||||||
this.state = this.reducer(this.state, action);
|
this.state = this.reducer(this.state, action);
|
||||||
this.listeners.forEach((listener) => listener(this.state));
|
this.listeners.forEach((listener) => listener(this.state));
|
||||||
|
_render(); // Re-render on state change
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener) {
|
subscribe(listener) {
|
||||||
@ -235,7 +295,6 @@ class Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create store instance
|
|
||||||
const store = new Store(actorReducer, initialState);
|
const store = new Store(actorReducer, initialState);
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@ -253,94 +312,149 @@ const selectors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// #region UI COMPONENTS (Redux-connected)
|
// #region UI COMPONENTS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
class ActorUI {
|
// Tooltip state
|
||||||
constructor(store) {
|
let tooltipEl = null;
|
||||||
this.store = store;
|
|
||||||
this.unsubscribe = 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() {
|
function showTooltip(item, x, y) {
|
||||||
console.log("ActorUI initializing...");
|
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
|
function hideTooltip() {
|
||||||
this.unsubscribe = this.store.subscribe((state) => {
|
if (tooltipEl) {
|
||||||
this.render(state);
|
tooltipEl.classList.remove("visible");
|
||||||
});
|
|
||||||
|
|
||||||
// Initial render
|
|
||||||
this.render(this.store.getState());
|
|
||||||
|
|
||||||
// Request initial data
|
|
||||||
this.requestInitialData();
|
|
||||||
|
|
||||||
console.log("ActorUI initialized successfully");
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render(state) {
|
function RadialItem({ item, index, total, onClick }) {
|
||||||
this.updateMenuDisplay(state);
|
const menuRadius = 160;
|
||||||
}
|
const itemSize = 80;
|
||||||
|
|
||||||
updateMenuDisplay(state) {
|
// Calculate position in circle
|
||||||
const grid = document.getElementById("menuGrid");
|
const angleStep = (2 * Math.PI) / total;
|
||||||
if (!grid) {
|
const angle = angleStep * index - Math.PI / 2; // Start from top
|
||||||
console.error("Menu grid element not found");
|
|
||||||
return;
|
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
|
return el;
|
||||||
grid.innerHTML = "";
|
}
|
||||||
|
|
||||||
// Render menu items
|
function RadialCenter({ onClose }) {
|
||||||
const menuItems = selectors.getMenuItems(state);
|
return h(
|
||||||
menuItems.forEach((item) => {
|
"div",
|
||||||
const menuItem = document.createElement("div");
|
{
|
||||||
menuItem.className = "neu-menu-item";
|
className: "radial-center",
|
||||||
menuItem.setAttribute("data-action", item.action);
|
onClick: onClose,
|
||||||
menuItem.innerHTML = `
|
},
|
||||||
<div class="neu-menu-item-icon">${item.icon}</div>
|
h("div", { className: "center-label" }, "Close"),
|
||||||
<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);
|
function RadialMenu() {
|
||||||
});
|
const state = store.getState();
|
||||||
|
const menuItems = selectors.getMenuItems(state);
|
||||||
|
|
||||||
console.log(`Rendered ${menuItems.length} menu items`);
|
const handleItemClick = (item) => {
|
||||||
}
|
|
||||||
|
|
||||||
handleMenuItemClick(item) {
|
|
||||||
console.log("Menu item clicked:", item);
|
console.log("Menu item clicked:", item);
|
||||||
const alert = {
|
const alert = {
|
||||||
event: item.action,
|
event: item.action,
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
A3API.SendAlert(JSON.stringify(alert));
|
if (typeof A3API !== "undefined") {
|
||||||
}
|
A3API.SendAlert(JSON.stringify(alert));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
requestInitialData() {
|
const handleClose = () => {
|
||||||
console.log("Requesting initial actor data...");
|
console.log("Close menu requested");
|
||||||
const alert = {
|
const alert = {
|
||||||
event: "actor::get::actions",
|
event: "actor::close::menu",
|
||||||
data: {},
|
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() {
|
return h(
|
||||||
if (this.unsubscribe) {
|
"div",
|
||||||
this.unsubscribe();
|
{ 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) {
|
function updateAvailableActions(actionTypes) {
|
||||||
@ -354,78 +468,45 @@ function handleGetActionsResponse(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// #region ACTION HANDLERS
|
// #region INITIALIZATION
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
function handleMenuItemClick(item) {
|
let initialized = false;
|
||||||
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() {
|
function initializeMenu() {
|
||||||
console.log("initializeMenu() called");
|
console.log("initializeMenu() called");
|
||||||
|
|
||||||
if (actorUIInitialized) {
|
if (initialized) {
|
||||||
console.log("ActorUI already initialized, skipping...");
|
console.log("Menu already initialized, skipping...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if DOM is ready
|
const root = document.getElementById("app");
|
||||||
if (document.readyState === "loading") {
|
if (root) {
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
render(App, root);
|
||||||
if (!actorUIInitialized) {
|
initialized = true;
|
||||||
console.log("DOM loaded, initializing ActorUI...");
|
console.log("Interaction menu initialized successfully");
|
||||||
window.actorUI = new ActorUI(store);
|
|
||||||
window.actorUI.init();
|
// Request initial data from A3API
|
||||||
actorUIInitialized = true;
|
if (typeof A3API !== "undefined") {
|
||||||
}
|
const alert = {
|
||||||
});
|
event: "actor::get::actions",
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
A3API.SendAlert(JSON.stringify(alert));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// DOM is already ready
|
console.error("Root element #app not found");
|
||||||
console.log("DOM already ready, initializing ActorUI...");
|
|
||||||
window.actorUI = new ActorUI(store);
|
|
||||||
window.actorUI.init();
|
|
||||||
actorUIInitialized = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
// Auto-initialize based on DOM state
|
||||||
// #region GLOBAL VARIABLES
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
// Make actorUI globally accessible
|
|
||||||
let actorUI;
|
|
||||||
|
|
||||||
// Auto-initialize if DOM is already loaded when script executes
|
|
||||||
if (document.readyState !== "loading") {
|
if (document.readyState !== "loading") {
|
||||||
console.log("Script loaded after DOM ready, auto-initializing...");
|
console.log("Script loaded after DOM ready, auto-initializing...");
|
||||||
if (!actorUIInitialized) {
|
initializeMenu();
|
||||||
actorUI = new ActorUI(store);
|
|
||||||
actorUI.init();
|
|
||||||
actorUIInitialized = true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Wait for DOM to be ready
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (!actorUIInitialized) {
|
console.log("DOM loaded, initializing menu...");
|
||||||
console.log("DOM loaded, initializing ActorUI...");
|
initializeMenu();
|
||||||
actorUI = new ActorUI(store);
|
|
||||||
actorUI.init();
|
|
||||||
actorUIInitialized = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -5,112 +23,168 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: var(--bg-app);
|
||||||
font-family: Arial, sans-serif;
|
color: var(--text-main);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
#app {
|
||||||
align-items: flex-end;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
box-shadow: var(--shadow-lg);
|
||||||
padding-right: 5%;
|
z-index: 10;
|
||||||
perspective: 1200px;
|
cursor: pointer;
|
||||||
}
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.neu-menu {
|
&:hover {
|
||||||
background: rgba(15, 20, 30, 0.9);
|
background: var(--bg-surface-hover);
|
||||||
border: 1px solid rgba(100, 150, 200, 0.4);
|
border-color: var(--primary);
|
||||||
border-radius: 4px;
|
transform: translate(-50%, -50%) scale(1.05);
|
||||||
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);
|
|
||||||
|
|
||||||
.neu-menu-content {
|
.center-icon {
|
||||||
height: 100%;
|
font-size: 1.25rem;
|
||||||
overflow: hidden;
|
margin-bottom: 0.15rem;
|
||||||
padding: 1rem;
|
}
|
||||||
|
|
||||||
.neu-menu-grid {
|
.center-label {
|
||||||
display: grid;
|
font-size: 0.65rem;
|
||||||
max-height: 380px;
|
font-weight: 600;
|
||||||
overflow-y: auto;
|
color: var(--text-muted);
|
||||||
overflow-x: hidden;
|
text-transform: uppercase;
|
||||||
scrollbar-width: thin;
|
letter-spacing: 0.05em;
|
||||||
-webkit-scrollbar-width: thin;
|
}
|
||||||
|
}
|
||||||
.neu-menu-item {
|
|
||||||
align-items: flex-start;
|
/* Menu Items */
|
||||||
background: rgba(20, 30, 45, 0.7);
|
.radial-item {
|
||||||
border-left: 3px solid rgba(100, 150, 200, 0.5);
|
position: absolute;
|
||||||
border-radius: 2px;
|
width: var(--item-size);
|
||||||
color: rgba(200, 220, 240, 0.95);
|
height: var(--item-size);
|
||||||
display: flex;
|
background: var(--bg-surface);
|
||||||
flex-direction: column;
|
border: 1px solid var(--border);
|
||||||
justify-content: center;
|
border-radius: var(--radius);
|
||||||
margin-bottom: 0.5rem;
|
display: flex;
|
||||||
min-height: 70px;
|
flex-direction: column;
|
||||||
padding: 0.75rem 1rem;
|
align-items: center;
|
||||||
text-align: left;
|
justify-content: center;
|
||||||
transition: all 0.15s ease;
|
padding: 0.5rem;
|
||||||
position: relative;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
&::before {
|
box-shadow: var(--shadow);
|
||||||
content: '';
|
text-align: center;
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
&:hover {
|
||||||
top: 0;
|
background: var(--bg-surface-hover);
|
||||||
height: 100%;
|
border-color: var(--primary);
|
||||||
width: 3px;
|
transform: scale(1.15);
|
||||||
background: rgba(100, 150, 200, 0.8);
|
box-shadow: var(--shadow-lg);
|
||||||
opacity: 0;
|
z-index: 5;
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
.radial-item-title {
|
||||||
|
color: var(--primary-hover);
|
||||||
&:last-child {
|
}
|
||||||
margin-bottom: 0 !important;
|
}
|
||||||
}
|
|
||||||
|
&:active {
|
||||||
&:hover {
|
transform: scale(0.95);
|
||||||
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),
|
.radial-item-icon {
|
||||||
inset 0 0 30px rgba(100, 150, 200, 0.05);
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
&::before {
|
|
||||||
opacity: 1;
|
.radial-item-title {
|
||||||
}
|
font-size: 0.6rem;
|
||||||
}
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
.neu-menu-item-description {
|
line-height: 1.2;
|
||||||
color: rgba(140, 160, 180, 0.85);
|
transition: color 0.2s ease;
|
||||||
font-size: 0.8rem;
|
max-width: 100%;
|
||||||
line-height: 1.3;
|
overflow: hidden;
|
||||||
margin-top: 0.35rem;
|
text-overflow: ellipsis;
|
||||||
}
|
display: -webkit-box;
|
||||||
|
line-clamp: 2;
|
||||||
.neu-menu-item-icon {
|
-webkit-line-clamp: 2;
|
||||||
display: none;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neu-menu-item-title {
|
/* Tooltip */
|
||||||
color: rgba(200, 220, 255, 1);
|
.radial-tooltip {
|
||||||
font-size: 1rem;
|
position: fixed;
|
||||||
font-weight: 600;
|
background: var(--primary-hover);
|
||||||
letter-spacing: 0.5px;
|
color: var(--text-inverse);
|
||||||
text-transform: uppercase;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_bank
|
# forge_client_bank
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
PREP(handleUIEvents);
|
PREP(handleUIEvents);
|
||||||
PREP(initBankClass);
|
PREP(initClass);
|
||||||
|
PREP(initSessionService);
|
||||||
|
PREP(initUIBridge);
|
||||||
PREP(openUI);
|
PREP(openUI);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#include "script_component.hpp"
|
#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), {
|
[QGVAR(initBank), {
|
||||||
GVAR(BankClass) call ["init", []];
|
GVAR(BankClass) call ["init", []];
|
||||||
@ -10,12 +12,18 @@ if (isNil QGVAR(BankClass)) then { [] call FUNC(initBankClass); };
|
|||||||
params [["_data", createHashMap, [createHashMap]]];
|
params [["_data", createHashMap, [createHashMap]]];
|
||||||
|
|
||||||
GVAR(BankClass) call ["sync", [_data, true]];
|
GVAR(BankClass) call ["sync", [_data, true]];
|
||||||
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
|
GVAR(BankUIBridge) call ["refreshSession", []];
|
||||||
|
};
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(responseSyncBank), {
|
[QGVAR(responseSyncBank), {
|
||||||
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
|
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
|
||||||
|
|
||||||
GVAR(BankClass) call ["sync", [_data, _jip]];
|
GVAR(BankClass) call ["sync", [_data, _jip]];
|
||||||
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
|
GVAR(BankUIBridge) call ["refreshSession", []];
|
||||||
|
};
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[{
|
[{
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class CfgPatches {
|
|||||||
name = COMPONENT_NAME;
|
name = COMPONENT_NAME;
|
||||||
requiredVersion = REQUIRED_VERSION;
|
requiredVersion = REQUIRED_VERSION;
|
||||||
requiredAddons[] = {
|
requiredAddons[] = {
|
||||||
|
"forge_client_common",
|
||||||
"forge_client_main"
|
"forge_client_main"
|
||||||
};
|
};
|
||||||
units[] = {};
|
units[] = {};
|
||||||
|
|||||||
@ -1,19 +1,25 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_handleUIEvents.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2025-12-16
|
||||||
|
* Last Update: 2026-02-17
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Handles the UI events.
|
* Handles the UI events.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* 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:
|
* Return Value:
|
||||||
* None
|
* UI events handled [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_bank_fnc_handleUIEvents;
|
* call forge_client_bank_fnc_handleUIEvents;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
params ["_control", "_isConfirmDialog", "_message"];
|
params ["_control", "_isConfirmDialog", "_message"];
|
||||||
@ -21,90 +27,49 @@ params ["_control", "_isConfirmDialog", "_message"];
|
|||||||
private _alert = fromJSON _message;
|
private _alert = fromJSON _message;
|
||||||
private _event = _alert get "event";
|
private _event = _alert get "event";
|
||||||
private _data = _alert get "data";
|
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];
|
diag_log format ["[FORGE:Client:Bank] Handling UI event: %1 with data: %2", _event, _data];
|
||||||
|
|
||||||
switch (_event) do {
|
switch (_event) do {
|
||||||
// ========================================================================
|
case "bank::close": {
|
||||||
// DATA REQUESTS
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
// ========================================================================
|
GVAR(BankUIBridge) call ["handleClose", []];
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private _fromAmount = _account get _from;
|
closeDialog 1;
|
||||||
if (_amount > _fromAmount) exitWith { hint "Insufficient funds!"; };
|
|
||||||
|
|
||||||
[SRPC(bank,requestTransfer), [_uid, _target, _from, _amount]] call CFUNC(serverEvent);
|
|
||||||
};
|
};
|
||||||
case "bank::close": {
|
case "bank::ready": {
|
||||||
_display closeDisplay 1;
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
|
GVAR(BankUIBridge) call ["handleReady", [_control, _data]];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
case "bank::refresh": {
|
||||||
// ========================================================================
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
// ATM OPERATIONS
|
GVAR(BankUIBridge) call ["refreshSession", []];
|
||||||
// ========================================================================
|
};
|
||||||
case "atm::withdraw": {
|
|
||||||
private _amount = _data get "amount";
|
|
||||||
if (_amount > _bank) exitWith { hint "Insufficient funds!"; };
|
|
||||||
|
|
||||||
[SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent);
|
|
||||||
};
|
};
|
||||||
case "atm::deposit": {
|
case "bank::deposit::request": {
|
||||||
private _amount = _data get "amount";
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
if (_amount > _cash) exitWith { hint "Insufficient cash!"; };
|
GVAR(BankUIBridge) call ["handleDepositRequest", [_data]];
|
||||||
|
};
|
||||||
[SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent);
|
|
||||||
};
|
};
|
||||||
case "atm::close": {
|
case "bank::withdraw::request": {
|
||||||
_display closeDisplay 1;
|
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 {
|
default {
|
||||||
diag_log format ["[FORGE:Client:Bank] Unhandled UI event: %1", _event];
|
hint format ["Unhandled bank UI event: %1", _event];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
62
arma/client/addons/bank/functions/fnc_initClass.sqf
Normal file
62
arma/client/addons/bank/functions/fnc_initClass.sqf
Normal 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)
|
||||||
80
arma/client/addons/bank/functions/fnc_initSessionService.sqf
Normal file
80
arma/client/addons/bank/functions/fnc_initSessionService.sqf
Normal 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)
|
||||||
134
arma/client/addons/bank/functions/fnc_initUIBridge.sqf
Normal file
134
arma/client/addons/bank/functions/fnc_initUIBridge.sqf
Normal 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)
|
||||||
@ -1,25 +1,29 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_openUI.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2026-01-28
|
||||||
|
* Last Update: 2026-01-30
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Opens the player bank interaction interface.
|
* Opens the player bank interaction interface.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* Arguments:
|
||||||
* None
|
* 0: [BOOL] - Whether to open the ATM interface
|
||||||
*
|
*
|
||||||
* Return Value:
|
* Return Value:
|
||||||
* None
|
* UI opened [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_bank_fnc_openUI;
|
* [true] call forge_client_bank_fnc_openUI;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
params [["_isATM", false, [false]]];
|
params [["_isATM", false, [false]]];
|
||||||
|
|
||||||
private _display = (findDisplay 46) createDisplay "RscBank";
|
private _display = createDialog ["RscBank", true];
|
||||||
private _ctrl = (_display displayCtrl 1002);
|
private _ctrl = _display displayCtrl 1002;
|
||||||
|
|
||||||
_ctrl ctrlAddEventHandler ["JSDialog", {
|
_ctrl ctrlAddEventHandler ["JSDialog", {
|
||||||
params ["_control", "_isConfirmDialog", "_message"];
|
params ["_control", "_isConfirmDialog", "_message"];
|
||||||
@ -27,11 +31,11 @@ _ctrl ctrlAddEventHandler ["JSDialog", {
|
|||||||
[_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
|
[_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
if (_isATM) then {
|
if !(isNil QGVAR(BankUIBridge)) then {
|
||||||
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\atm.html)];
|
GVAR(BankUIBridge) call ["setMode", [["bank", "atm"] select _isATM]];
|
||||||
} else {
|
GVAR(BankUIBridge) call ["setActiveBrowserControl", [_ctrl]];
|
||||||
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bank.html)];
|
|
||||||
};
|
};
|
||||||
// _ctrl ctrlWebBrowserAction ["OpenDevConsole"];
|
|
||||||
|
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)];
|
||||||
|
|
||||||
true;
|
true;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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;
|
|
||||||
1
arma/client/addons/bank/ui/_site/bank-ui.css
Normal file
1
arma/client/addons/bank/ui/_site/bank-ui.css
Normal file
File diff suppressed because one or more lines are too long
1
arma/client/addons/bank/ui/_site/bank-ui.js
Normal file
1
arma/client/addons/bank/ui/_site/bank-ui.js
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
1
arma/client/addons/bank/ui/_site/index.html
Normal file
1
arma/client/addons/bank/ui/_site/index.html
Normal 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.
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
@ -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;
|
|
||||||
}
|
|
||||||
116
arma/client/addons/bank/ui/src/bootstrap.js
vendored
Normal file
116
arma/client/addons/bank/ui/src/bootstrap.js
vendored
Normal 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();
|
||||||
|
})();
|
||||||
51
arma/client/addons/bank/ui/src/bridge.js
Normal file
51
arma/client/addons/bank/ui/src/bridge.js
Normal 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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
104
arma/client/addons/bank/ui/src/components/AppShell.js
Normal file
104
arma/client/addons/bank/ui/src/components/AppShell.js
Normal 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" }),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
})();
|
||||||
91
arma/client/addons/bank/ui/src/components/BankSidebar.js
Normal file
91
arma/client/addons/bank/ui/src/components/BankSidebar.js
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
})();
|
||||||
72
arma/client/addons/bank/ui/src/components/Footer.js
Normal file
72
arma/client/addons/bank/ui/src/components/Footer.js
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
})();
|
||||||
189
arma/client/addons/bank/ui/src/components/common.js
Normal file
189
arma/client/addons/bank/ui/src/components/common.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
44
arma/client/addons/bank/ui/src/data.js
Normal file
44
arma/client/addons/bank/ui/src/data.js
Normal 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 || {}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
238
arma/client/addons/bank/ui/src/pages/ATMView.js
Normal file
238
arma/client/addons/bank/ui/src/pages/ATMView.js
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
})();
|
||||||
321
arma/client/addons/bank/ui/src/pages/BankView.js
Normal file
321
arma/client/addons/bank/ui/src/pages/BankView.js
Normal 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;
|
||||||
|
})();
|
||||||
343
arma/client/addons/bank/ui/src/registry/events.js
Normal file
343
arma/client/addons/bank/ui/src/registry/events.js
Normal 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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
63
arma/client/addons/bank/ui/src/registry/store.js
Normal file
63
arma/client/addons/bank/ui/src/registry/store.js
Normal 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();
|
||||||
|
})();
|
||||||
6
arma/client/addons/bank/ui/src/runtime.js
Normal file
6
arma/client/addons/bank/ui/src/runtime.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
(function () {
|
||||||
|
const runtime = window.ForgeWebUI;
|
||||||
|
const BankApp = (window.BankApp = window.BankApp || {});
|
||||||
|
BankApp.runtime = runtime;
|
||||||
|
window.AppRuntime = runtime;
|
||||||
|
})();
|
||||||
590
arma/client/addons/bank/ui/src/styles.css
Normal file
590
arma/client/addons/bank/ui/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
arma/client/addons/bank/ui/ui.config.mjs
Normal file
38
arma/client/addons/bank/ui/ui.config.mjs
Normal 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"],
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
forge_client_common
|
# forge_client_common
|
||||||
===================
|
|
||||||
|
|
||||||
Common functionality shared between addons.
|
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.
|
||||||
|
|||||||
991
arma/client/addons/common/WEB_UI_FRAMEWORK.md
Normal file
991
arma/client/addons/common/WEB_UI_FRAMEWORK.md
Normal 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.
|
||||||
@ -1 +1,2 @@
|
|||||||
|
|
||||||
|
PREP(initWebUIBridge);
|
||||||
|
|||||||
209
arma/client/addons/common/functions/fnc_initWebUIBridge.sqf
Normal file
209
arma/client/addons/common/functions/fnc_initWebUIBridge.sqf
Normal 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)]
|
||||||
|
]
|
||||||
1
arma/client/addons/common/ui/_site/forge-site-loader.js
Normal file
1
arma/client/addons/common/ui/_site/forge-site-loader.js
Normal 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);
|
||||||
1
arma/client/addons/common/ui/_site/forge-webui.js
Normal file
1
arma/client/addons/common/ui/_site/forge-webui.js
Normal file
File diff suppressed because one or more lines are too long
60
arma/client/addons/common/ui/src/app.js
Normal file
60
arma/client/addons/common/ui/src/app.js
Normal 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);
|
||||||
128
arma/client/addons/common/ui/src/bridge.js
Normal file
128
arma/client/addons/common/ui/src/bridge.js
Normal 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);
|
||||||
68
arma/client/addons/common/ui/src/host.js
Normal file
68
arma/client/addons/common/ui/src/host.js
Normal 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);
|
||||||
5
arma/client/addons/common/ui/src/index.js
Normal file
5
arma/client/addons/common/ui/src/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
(function (global) {
|
||||||
|
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
|
||||||
|
|
||||||
|
ForgeWebUI.version = "0.1.0";
|
||||||
|
})(window);
|
||||||
428
arma/client/addons/common/ui/src/runtime.js
Normal file
428
arma/client/addons/common/ui/src/runtime.js
Normal 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);
|
||||||
126
arma/client/addons/common/ui/src/siteLoader.js
Normal file
126
arma/client/addons/common/ui/src/siteLoader.js
Normal 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);
|
||||||
238
arma/client/addons/common/ui/src/windowTitleBar.js
Normal file
238
arma/client/addons/common/ui/src/windowTitleBar.js
Normal 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);
|
||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_garage
|
# forge_client_garage
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
PREP(initGarageClass);
|
PREP(handleUIEvents);
|
||||||
|
PREP(initCatalogService);
|
||||||
|
PREP(initClass);
|
||||||
|
PREP(initSessionService);
|
||||||
|
PREP(initUIBridge);
|
||||||
PREP(initVGClass);
|
PREP(initVGClass);
|
||||||
|
PREP(openUI);
|
||||||
PREP(openVG);
|
PREP(openVG);
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
#include "script_component.hpp"
|
#include "script_component.hpp"
|
||||||
|
|
||||||
if (isNil QGVAR(GarageClass)) then { [] call FUNC(initGarageClass); };
|
if (isNil QGVAR(GarageCatalogService)) then { call FUNC(initCatalogService); };
|
||||||
if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); };
|
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), {
|
[QGVAR(initGarage), {
|
||||||
GVAR(GarageClass) call ["init", []];
|
GVAR(GarageClass) call ["init", []];
|
||||||
@ -10,29 +13,43 @@ if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); };
|
|||||||
[QGVAR(responseInitGarage), {
|
[QGVAR(responseInitGarage), {
|
||||||
params [["_data", createHashMap, [createHashMap]]];
|
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);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(responseSyncGarage), {
|
[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);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(initVG), {
|
[QGVAR(initVG), {
|
||||||
GVAR(VGarageClass) call ["init", []];
|
GVAR(VGClass) call ["init", []];
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(responseInitVG), {
|
[QGVAR(responseInitVG), {
|
||||||
params [["_data", createHashMap, [createHashMap]]];
|
params [["_data", createHashMap, [createHashMap]]];
|
||||||
|
|
||||||
GVAR(VGarageClass) call ["sync", [_data, true]];
|
GVAR(VGClass) call ["sync", [_data]];
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(responseSyncVG), {
|
[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);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[{
|
[{
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
#include "script_component.hpp"
|
#include "script_component.hpp"
|
||||||
|
|
||||||
#include "XEH_PREP.hpp"
|
#include "XEH_PREP.hpp"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class CfgPatches {
|
|||||||
name = COMPONENT_NAME;
|
name = COMPONENT_NAME;
|
||||||
requiredVersion = REQUIRED_VERSION;
|
requiredVersion = REQUIRED_VERSION;
|
||||||
requiredAddons[] = {
|
requiredAddons[] = {
|
||||||
|
"forge_client_common",
|
||||||
"forge_client_main"
|
"forge_client_main"
|
||||||
};
|
};
|
||||||
units[] = {};
|
units[] = {};
|
||||||
@ -17,3 +18,5 @@ class CfgPatches {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#include "CfgEventHandlers.hpp"
|
#include "CfgEventHandlers.hpp"
|
||||||
|
#include "ui\RscCommon.hpp"
|
||||||
|
#include "ui\RscGarage.hpp"
|
||||||
|
|||||||
@ -1,17 +1,66 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_handleUIEvents.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2025-12-16
|
||||||
|
* Last Update: 2026-01-30
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Handles the UI events.
|
* Handles the UI events.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* 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:
|
* Return Value:
|
||||||
* None
|
* UI events handled [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_garage_fnc_handleUIEvents;
|
* call forge_client_garage_fnc_handleUIEvents;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
160
arma/client/addons/garage/functions/fnc_initCatalogService.sqf
Normal file
160
arma/client/addons/garage/functions/fnc_initCatalogService.sqf
Normal 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)
|
||||||
@ -1,31 +1,36 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_initClass.sqf
|
||||||
* Author: IDSolutions
|
* 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:
|
* Arguments:
|
||||||
* None
|
* None
|
||||||
*
|
*
|
||||||
* Return Value:
|
* Return Value:
|
||||||
* None
|
* Garage class object [HASHMAP OBJECT]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_garage_fnc_initGarageClass;
|
* call forge_client_garage_fnc_initClass
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#pragma hemtt ignore_variables ["_self"]
|
#pragma hemtt ignore_variables ["_self"]
|
||||||
GVAR(GarageClass) = createHashMapObject [[
|
GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [
|
||||||
["#type", "IGarageClass"],
|
["#type", "GarageBaseClass"],
|
||||||
["#create", {
|
["#create", compileFinal {
|
||||||
_self set ["uid", (getPlayerUID player)];
|
_self set ["uid", (getPlayerUID player)];
|
||||||
_self set ["garage", createHashMap];
|
_self set ["garage", createHashMap];
|
||||||
_self set ["isLoaded", false];
|
_self set ["isLoaded", false];
|
||||||
_self set ["lastSave", time];
|
_self set ["lastSave", time];
|
||||||
}],
|
}],
|
||||||
["init", {
|
["init", compileFinal {
|
||||||
private _uid = _self get "uid";
|
private _uid = _self get "uid";
|
||||||
private _garage = _self get "garage";
|
private _garage = _self get "garage";
|
||||||
|
|
||||||
@ -34,40 +39,34 @@ GVAR(GarageClass) = createHashMapObject [[
|
|||||||
systemChat format ["Garage loaded for %1", (name player)];
|
systemChat format ["Garage loaded for %1", (name player)];
|
||||||
diag_log "[FORGE:Client:Garage] Garage Class Initialized!";
|
diag_log "[FORGE:Client:Garage] Garage Class Initialized!";
|
||||||
}],
|
}],
|
||||||
["save", {
|
["save", compileFinal {
|
||||||
params [["_sync", false, [false]]];
|
|
||||||
|
|
||||||
private _uid = _self get "uid";
|
private _uid = _self get "uid";
|
||||||
[SRPC(garage,requestSaveGarage), [_uid, _sync]] call CFUNC(serverEvent);
|
[SRPC(garage,requestSaveGarage), [_uid]] call CFUNC(serverEvent);
|
||||||
|
|
||||||
_self set ["lastSave", time];
|
_self set ["lastSave", time];
|
||||||
}],
|
}],
|
||||||
["sync", {
|
["sync", compileFinal {
|
||||||
params [["_data", createHashMap, [createHashMap]], ["_sync", false, [false]]];
|
params [["_data", createHashMap, [createHashMap]]];
|
||||||
|
|
||||||
private _garage = _self get "garage";
|
|
||||||
private _isLoaded = _self get "isLoaded";
|
private _isLoaded = _self get "isLoaded";
|
||||||
|
private _garage = createHashMap;
|
||||||
|
|
||||||
if (_data isEqualTo createHashMap) exitWith {
|
{ _garage set [_x, _y]; } forEach _data;
|
||||||
diag_log "[FORGE:Client:Garage] Empty data received for sync, skipping.";
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
_garage set [_x, _y];
|
|
||||||
} forEach _data;
|
|
||||||
|
|
||||||
_self set ["garage", _garage];
|
_self set ["garage", _garage];
|
||||||
|
|
||||||
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
||||||
diag_log "[FORGE:Client:Garage] Sync completed";
|
diag_log "[FORGE:Client:Garage] Sync completed";
|
||||||
}],
|
}],
|
||||||
["get", {
|
["getGarageState", compileFinal {
|
||||||
|
_self getOrDefault ["garage", createHashMap]
|
||||||
|
}],
|
||||||
|
["get", compileFinal {
|
||||||
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
||||||
|
|
||||||
private _garage = _self get "garage";
|
private _garage = _self get "garage";
|
||||||
_garage getOrDefault [_key, _default];
|
_garage getOrDefault [_key, _default];
|
||||||
}]
|
}]
|
||||||
]];
|
];
|
||||||
|
|
||||||
SETVAR(player,FORGE_GarageClass,GVAR(GarageClass));
|
GVAR(GarageClass) = createHashMapObject [GVAR(GarageBaseClass)];
|
||||||
GVAR(GarageClass)
|
GVAR(GarageClass)
|
||||||
298
arma/client/addons/garage/functions/fnc_initSessionService.sqf
Normal file
298
arma/client/addons/garage/functions/fnc_initSessionService.sqf
Normal 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)
|
||||||
205
arma/client/addons/garage/functions/fnc_initUIBridge.sqf
Normal file
205
arma/client/addons/garage/functions/fnc_initUIBridge.sqf
Normal 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)
|
||||||
@ -1,46 +1,38 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* File: fnc_initVGarageClass.sqf
|
* File: fnc_initVGClass.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
* Date: 2025-12-16
|
* Date: 2025-12-16
|
||||||
* Last Update: 2025-12-19
|
* Last Update: 2026-02-13
|
||||||
* Public: No
|
* Public: No
|
||||||
*
|
*
|
||||||
* Description:
|
* Description:
|
||||||
* Initializes the Virtual Garage class for managing player garage unlocks.
|
* Initializes the Virtual Garage class for managing player garage unlocks.
|
||||||
* Provides methods for syncing, saving, and applying virtual items to BIS Garage.
|
* Provides methods for syncing, saving, and applying virtual items to BIS Garage.
|
||||||
*
|
*
|
||||||
* Parameter(s):
|
* Arguments:
|
||||||
* None
|
* None
|
||||||
*
|
*
|
||||||
* Returns:
|
* Return Value:
|
||||||
* vGarage class object [HASHMAP OBJECT]
|
* vGarage class object [HASHMAP OBJECT]
|
||||||
*
|
*
|
||||||
* Example(s):
|
* Example:
|
||||||
* [] call forge_client_locker_fnc_initVGClass;
|
* call forge_client_garage_fnc_initVGClass;
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#pragma hemtt ignore_variables ["_self"]
|
#pragma hemtt ignore_variables ["_self"]
|
||||||
GVAR(VGarageClass) = createHashMapObject [[
|
GVAR(VGBaseClass) = compileFinal createHashMapFromArray [
|
||||||
["#type", "IVGarageClass"],
|
["#type", "VGBaseClass"],
|
||||||
["#create", {
|
["#create", compileFinal {
|
||||||
|
GVAR(isPreLoaded) = false;
|
||||||
|
|
||||||
_self set ["uid", (getPlayerUID player)];
|
_self set ["uid", (getPlayerUID player)];
|
||||||
_self set ["vGarage", createHashMap];
|
_self set ["vGarage", createHashMap];
|
||||||
_self set ["isLoaded", false];
|
_self set ["isLoaded", false];
|
||||||
_self set ["lastSave", time];
|
_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 _uid = _self get "uid";
|
||||||
private _vGarage = _self get "vGarage";
|
private _vGarage = _self get "vGarage";
|
||||||
|
|
||||||
@ -49,35 +41,29 @@ GVAR(VGarageClass) = createHashMapObject [[
|
|||||||
systemChat format ["VGarage loaded for %1", (name player)];
|
systemChat format ["VGarage loaded for %1", (name player)];
|
||||||
diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!";
|
diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!";
|
||||||
}],
|
}],
|
||||||
["save", {
|
["save", compileFinal {
|
||||||
params [["_sync", false, [false]]];
|
|
||||||
|
|
||||||
private _uid = _self get "uid";
|
private _uid = _self get "uid";
|
||||||
[SRPC(garage,requestSaveVG), [_uid, _sync]] call CFUNC(serverEvent);
|
[SRPC(garage,requestSaveVG), [_uid]] call CFUNC(serverEvent);
|
||||||
|
|
||||||
_self set ["lastSave", time];
|
_self set ["lastSave", time];
|
||||||
}],
|
}],
|
||||||
["sync", {
|
["sync", compileFinal {
|
||||||
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
|
params [["_data", createHashMap, [createHashMap]]];
|
||||||
|
|
||||||
private _vGarage = _self get "vGarage";
|
private _vGarage = _self get "vGarage";
|
||||||
private _isLoaded = _self get "isLoaded";
|
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];
|
_vGarage set [_x, _y];
|
||||||
|
|
||||||
if (_jip) then {
|
switch (_x) do {
|
||||||
switch (_x) do {
|
case "cars": { _self call ["apply", ["cars"]]; };
|
||||||
case "cars": { _self call ["apply", ["cars"]]; };
|
case "armor": { _self call ["apply", ["armor"]]; };
|
||||||
case "armor": { _self call ["apply", ["armor"]]; };
|
case "helis": { _self call ["apply", ["helis"]]; };
|
||||||
case "helis": { _self call ["apply", ["helis"]]; };
|
case "planes": { _self call ["apply", ["planes"]]; };
|
||||||
case "planes": { _self call ["apply", ["planes"]]; };
|
case "naval": { _self call ["apply", ["naval"]]; };
|
||||||
case "naval": { _self call ["apply", ["naval"]]; };
|
case "other": { _self call ["apply", ["other"]]; };
|
||||||
case "other": { _self call ["apply", ["other"]]; };
|
default {};
|
||||||
default {};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
} forEach _data;
|
} forEach _data;
|
||||||
|
|
||||||
@ -86,31 +72,33 @@ GVAR(VGarageClass) = createHashMapObject [[
|
|||||||
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
if !(_isLoaded) then { _self set ["isLoaded", true]; };
|
||||||
diag_log "[FORGE:Client:VGarage] Sync completed";
|
diag_log "[FORGE:Client:VGarage] Sync completed";
|
||||||
}],
|
}],
|
||||||
["get", {
|
["get", compileFinal {
|
||||||
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
|
||||||
|
|
||||||
private _vGarage = _self get "vGarage";
|
private _vGarage = _self get "vGarage";
|
||||||
_vGarage getOrDefault [_key, _default];
|
_vGarage getOrDefault [_key, _default];
|
||||||
}],
|
}],
|
||||||
["apply", {
|
["apply", compileFinal {
|
||||||
params [["_key", "", [""]]];
|
params [["_key", "", [""]]];
|
||||||
|
|
||||||
private _vehicles = _self call ["get", [_key, []]];
|
private _vehicles = _self call ["get", [_key, []]];
|
||||||
private _array = switch (_key) do {
|
private _appliedVehicles = [];
|
||||||
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 { [] };
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
{
|
||||||
_array append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]];
|
_appliedVehicles append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]];
|
||||||
} forEach _vehicles;
|
} forEach _vehicles;
|
||||||
}]
|
|
||||||
]];
|
|
||||||
|
|
||||||
SETVAR(player,FORGE_VGarageClass,GVAR(VGarageClass));
|
switch (_key) do {
|
||||||
GVAR(VGarageClass)
|
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)
|
||||||
|
|||||||
@ -1,17 +1,38 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
* File: fnc_openUI.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
|
* Date: 2025-12-16
|
||||||
|
* Last Update: 2026-01-30
|
||||||
|
* Public: No
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
* Opens the garage UI.
|
* Opens the garage UI.
|
||||||
*
|
*
|
||||||
* Arguments:
|
* Arguments:
|
||||||
* None
|
* None
|
||||||
*
|
*
|
||||||
* Return Value:
|
* Return Value:
|
||||||
* None
|
* UI opened [BOOL]
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* [] call forge_client_garage_fnc_openUI;
|
* call forge_client_garage_fnc_openUI;
|
||||||
*
|
|
||||||
* Public: No
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
#include "..\script_component.hpp"
|
#include "..\script_component.hpp"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* File: fnc_initVG.sqf
|
* File: fnc_openVG.sqf
|
||||||
* Author: IDSolutions
|
* Author: IDSolutions
|
||||||
* Date: 2025-12-16
|
* Date: 2025-12-16
|
||||||
* Last Update: 2025-12-17
|
* Last Update: 2026-01-30
|
||||||
* Public: No
|
* Public: No
|
||||||
*
|
*
|
||||||
* Description:
|
* Description:
|
||||||
* No description added yet.
|
* Opens the Virtual Garage.
|
||||||
*
|
*
|
||||||
* Parameter(s):
|
* Arguments:
|
||||||
* N/A
|
* None
|
||||||
*
|
*
|
||||||
* Returns:
|
* Return Value:
|
||||||
* Something [BOOL]
|
* None
|
||||||
*
|
*
|
||||||
* Example(s):
|
* Example:
|
||||||
* [parameter] call forge_x_component_fnc_myFunction
|
* call forge_client_garage_fnc_openVG
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData);
|
private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData);
|
||||||
@ -27,57 +27,69 @@ private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") ca
|
|||||||
} count _locations;
|
} count _locations;
|
||||||
|
|
||||||
BIS_fnc_garage_center = createVehicle ["Land_HelipadEmpty_F", FORGE_VehSpawnPos, [], 0, "NONE"];
|
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");
|
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);
|
["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);
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
class RscGarage {
|
class RscGarage {
|
||||||
idd = 1000;
|
idd = 1005;
|
||||||
fadeIn = 0;
|
fadeIn = 0;
|
||||||
fadeOut = 0;
|
fadeOut = 0;
|
||||||
duration = 1e011;
|
duration = 1e011;
|
||||||
@ -10,7 +10,7 @@ class RscGarage {
|
|||||||
class controls {
|
class controls {
|
||||||
class IFrame: RscText {
|
class IFrame: RscText {
|
||||||
type = 106;
|
type = 106;
|
||||||
idc = 1001;
|
idc = 1006;
|
||||||
x = "safeZoneXAbs";
|
x = "safeZoneXAbs";
|
||||||
y = "safeZoneY";
|
y = "safeZoneY";
|
||||||
w = "safeZoneWAbs";
|
w = "safeZoneWAbs";
|
||||||
|
|||||||
1
arma/client/addons/garage/ui/_site/garage-ui.css
Normal file
1
arma/client/addons/garage/ui/_site/garage-ui.css
Normal file
File diff suppressed because one or more lines are too long
1
arma/client/addons/garage/ui/_site/garage-ui.js
Normal file
1
arma/client/addons/garage/ui/_site/garage-ui.js
Normal file
File diff suppressed because one or more lines are too long
@ -1,205 +1 @@
|
|||||||
<!doctype 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>
|
||||||
<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>
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user