Compare commits

...
7 Commits
86 changed files with 9301 additions and 4525 deletions

No files matched your search

+502
View File
@@ -0,0 +1,502 @@
# Game State Architecture
## Purpose
The game separates authoritative gameplay state from rendering and runtime
resources. The same snapshot-and-command boundary supports the in-process
single-player server and a WebSocket deterministic-lockstep client/server.
## Architecture Overview
The architecture has six layers:
1. `GameState` is the aggregate root for persistent, authoritative state.
2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots.
3. `ClientLocationState` owns per-client `LocationLocalState` records, including camera orientation. Graphical hosts own this state; it is never replicated.
4. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands.
5. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`.
6. Protocol-v2 WebSocket hosts assign preconfigured player slots and exchange
one initial state followed by ordered 100 ms tick bundles.
```text
Input / UI
| typed GameCommand
v
IGameServer
|
v
InternalGameServer ---- owns ----> GameState
| |
| update | stable state objects
v v
Location / Character / InteractiveObject runtime
|
v
Renderer
IGameServer ---- immutable GameSnapshot ----> UI and other clients
```
## Authoritative State Model
### `GameState`
`GameState` is the top-level state object. It contains:
- Campaign ID and current location ID.
- Day, night, dawn, and darklands flags.
- A map of campaign-level players keyed by stable player ID.
- A map of typed `LocationGameState` records keyed by location ID.
- Inventory, money, player-health HUD values, quests, tutorial progress, chat data, taxi state, and script-global values.
Live `Location` pointers and rendering objects are no longer stored in `GameState`.
Embedded state models such as `Inventory` and `QuestJournal` contain only value
data and deterministic operations; they do not retain runtime callbacks.
### Campaign-level players
Player `CharacterState` objects are stored in `GameState::players`. The current single-player game uses the ID `"player"`.
The player state is no longer owned by a `Character` or duplicated as a separately authoritative state for each loaded location. This permits additional player IDs to be introduced later without changing the aggregate ownership model.
Every player, including the one controlled by the current client, remains in this
same authoritative collection. `IGameServer::localPlayerId()` identifies which
entry is local to a particular client connection; it is not a separate state
object.
`LocationGameState` does not retain a player pointer or back-reference to `GameState`.
Its update boundary receives `GameState::players` by reference, allowing character
lookup and simulation to treat every campaign player by stable ID.
### `LocationGameState`
Each location record owns:
- One `LocationNetworkState`.
- NPC `CharacterState` objects keyed by configured ID.
- `InteractiveObjectState` objects keyed by configured ID.
- Trigger-zone enabled/inside flags.
- Teleport-zone active flags.
- Serialized dialogue runtime state.
- Serialized Lua runtime globals.
World-mode flags (`darklands`, `night`, and `dawn`) exist only at the aggregate
`GameState` level. Location rendering and scripts read those values from the
top-level snapshot rather than maintaining per-location mirrors.
State objects use `shared_ptr` internally to keep addresses stable for the
remaining local runtime bindings. These pointers never appear in public snapshots.
### Stable identity
- Locations use campaign configuration IDs.
- Players use keys from `GameState::players`.
- NPCs use required location-local configuration IDs.
- Interactive objects use required location-local configuration IDs.
- Trigger and teleport zones use their configured IDs.
Missing, empty, duplicate, and reserved IDs are content errors. Runtime references,
Lua APIs, snapshots, and persistence all use these IDs rather than configuration
array positions.
## Server Interface
`IGameServer` is the public state boundary. It exposes:
- `snapshot()` for an immutable, revisioned `GameSnapshot`.
- `submit()` for typed `GameCommand` values.
- `update()` for advancing a local fixed tick or polling a remote implementation.
- `save()` and `load()` for server-side persistence.
No public method returns a mutable state pointer or reference.
### Snapshots
`GameSnapshot` and its nested snapshot types are value-only read models. They contain no renderer objects, runtime `Location` pointers, mutable references, or active callbacks.
Snapshots include:
- Campaign and world-mode state.
- All players.
- Locations, NPCs, interactive objects, triggers, and teleports.
- Inventory item IDs.
- Quest views.
- Tutorial, chat, taxi, money, and health data.
- Script-global values.
Snapshots have a monotonically increasing revision. `InternalGameServer` caches the current immutable snapshot and rebuilds it only after the authoritative state changes.
### Commands
`GameCommand` is a C++17 `std::variant` of semantic command types. Current commands cover:
- World mode and current location.
- Player health and money.
- Tutorial progression and flags.
- Chat history and unread state.
- Taxi state.
- Inventory additions and removals.
- Quest status, objective completion/visibility, and active-objective changes.
- Script globals.
- Character transforms, attack targets, navigation destinations, stopping,
facing, replanning, health, damage, death reset, attack combos, and animation
metadata.
- Interactive-object activation.
- Trigger-zone state.
- Quest-definition reloads.
The remote implementation currently serializes only
`SetCharacterDestinationCommand` as player intent. The server derives the
character and location from the assigned connection slot; arbitrary state
mutation commands are not accepted from the network.
### Presentation events
`IGameServer::drainEvents()` returns value-only events produced by successful
authoritative mutations. Inventory add/remove and quest unlock/complete/fail/
objective-complete events contain stable IDs only. `Game` drains these events and
performs local tutorial, toast, and audio presentation through `MenuManager`.
Loading and persistence restoration clear pending events and remain silent.
## Internal Server
`InternalGameServer` is the current `IGameServer` implementation. It:
- Exclusively owns the aggregate `GameState`.
- Executes typed commands against authoritative state.
- Advances the active location simulation in exact 100 ms ticks.
- Builds and caches immutable snapshots.
- Delegates save/load serialization to the aggregate state model.
`LocalGameServerHost` is the graphical client's integration adapter. It owns loaded
runtime `Location` objects, forwards simulation events to them, and synchronizes
dialogue, Lua, trigger, and teleport auxiliary state. `Game` uses the explicit
`IGameServerHost` lifecycle interface and is no longer a friend of
`InternalGameServer`. The standalone server does not compile this adapter.
Location loading has two phases. `LocationContentLoader` parses renderer-free
`LocationDefinition` data, and `LocationGameState::setup()` creates/reconciles
authoritative NPC, interactive-object, navigation, taxi, trigger, teleport, and
animation state. The graphical `Location::setup()` runs afterward and only builds
presentation objects from that definition and the prepared state. Lua and dialogue
runtime behavior remains client-side.
### Animation and combat
Logical animation frame clocks live in `CharacterState`. The renderer-free content
loader reads animation frame metadata before authoritative entities are created.
Runtime `Character` construction only loads models for presentation and does not
submit metadata mutations back to the server.
`CharacterState::update()` returns value-only events containing arrival Lua function
names, attack impacts, navigation requests, and death-animation completion.
`LocationGameState` applies queued damage after every character has advanced.
Navigation replans are consumed by `InternalGameServer` as deterministic
simulation consequences rather than being resubmitted by presentation code.
Runtime `Character` objects only pose loaded models and produce audio, particles,
and presentation callbacks.
### Character state operations
Gameplay operations mutate the authoritative `CharacterState`, not the runtime
`Character`:
- `setTarget()` normalizes the destination, avoids redundant requests, stores the
optional Lua arrival function name, and asks the authoritative `LocationNetworkState` to
call `planPathExceptMyself()`.
- `forceReplan()` recalculates the current requested destination using the same
location navigation state.
- `stopInPlace()` clears navigation and any pending arrival function.
- `stopAndRotateToTarget()`, `resetPlayerAfterDeath()`, `setHp()`, and
`tryAttackCombo()` update their respective state directly.
`InternalGameServer::submit()` resolves the character and location, then invokes
these operations for the corresponding semantic command. The runtime `Location`
provides convenience methods for submitting those commands from input, scripts,
collision handling, and presentation-event processing.
## Runtime and Visual Object Bindings
Runtime visual objects do not own their authoritative state objects.
- `Location` keeps its `IGameServer` reference, stable location ID, and a shared
`LocationLocalState` supplied by the graphical host. It resolves its immutable
`LocationNetworkState` view from `IGameServer::snapshot()`, while rendering and
camera input access the local state directly.
- `Character` keeps only its `IGameServer` reference plus stable entity and location IDs. It resolves the player-first character view from `IGameServer::snapshot()`.
- `InteractiveObject` keeps a private non-owning reference to its `InteractiveObjectState`; its configured object name is its stable ID.
`Character::stateSnapshot()` and `Location::stateSnapshot()` return aliasing
`shared_ptr` views. Each pointer addresses a state copy inside a `GameSnapshot`,
while shared ownership keeps that whole immutable snapshot alive. Update, input,
editor, and draw operations capture one such pointer and use it consistently
throughout the operation. A missing or unloaded entity produces an empty pointer
and presentation or input work is skipped.
The authoritative state objects are owned by `GameState` or `LocationGameState`.
The internal server advances them before publishing a new snapshot revision.
Runtime input, scripts, and collision handling submit semantic commands that invoke
authoritative character and location operations. Location commands cover pending
interaction targets, navigation switching and area changes, editor obstacles, and
taxi reset. Camera orientation is deliberately not a server command. No
whole-location commit command is exposed.
Navigation target selection, replanning, stopping, facing, death reset, health
assignment, and attack-combo clock changes are implemented by `CharacterState`.
`Character` no longer exposes gameplay mutation helpers or a generic state mutation
callback. Character construction is presentation-only; frame ranges are already
present in `CharacterState` after content setup. It focuses on model posing,
rendering, audio, particles, and runtime callbacks.
Death-animation and health-change callbacks remain runtime-only on `Character`.
Arrival behavior is represented solely by the serialized Lua function name in
`CharacterState`; `Location` resolves and invokes that name through `ScriptEngine`
when it consumes the arrival event.
`Location` owns presentation-only runtime characters for all non-local entries in
`GameSnapshot::players`. It reconciles them by stable ID as snapshots change,
reuses the local-player assets and animation set, and includes them in all render,
shadow, lighting, animation, damage, and name-tag passes. The local entry is
excluded using `IGameServer::localPlayerId()` because its visual is already owned
by `Game`.
`GameObjectLoader` now:
1. Receives immutable object/NPC definitions parsed by `LocationContentLoader`.
2. Resolves already-prepared authoritative entities by stable ID.
3. Constructs meshes, textures, VBOs, `InteractiveObject`, and `Character`
presentation wrappers without creating or overwriting authoritative state.
Configuration remains the source of visual assets, animation paths, meshes, textures, and fixed tuning data.
## State Update Flow
Render frames and simulation ticks are separate:
1. SDL input and UI presentation use the real elapsed render-frame time.
2. A local host accumulates `steady_clock` elapsed time and runs zero or more
exact 100 ms simulation ticks. A remote host polls transport every frame but
advances only from contiguous server tick messages.
3. Commands assigned to tick `N` are applied in server sequence order before
simulation tick `N`.
4. The server freezes location character observations and updates every player,
NPC, taxi, and interactive object with exactly 100 ms. An empty player
collection remains valid.
5. Attack impacts are queued and applied only after all characters advance.
Navigation recovery is applied inside the deterministic state path.
6. The server invalidates its cached snapshot and advances its revision.
7. Runtime locations consume the completed tick's value-only events and perform
rendering, audio, particles, and supported presentation callbacks.
8. Rendering can occur between ticks and never changes authoritative tick time.
Commands that update bound character or object state do not reload the whole location. This avoids replaying dialogue-node effects or Lua callbacks.
## Campaign and Location Lifecycle
Only one location is normally loaded as a runtime object.
### Campaign loading
When a campaign starts:
- Existing campaign state and runtime locations are cleared.
- Quest definitions are loaded.
- The graphical single-player host requests a campaign-level `"player"` state and
creates its visual wrapper.
- A standalone host may deliberately create no players; locations continue to tick.
- The battle-zone test bot is an explicit host option rather than an unconditional
campaign side effect.
### Location loading
When a location is loaded:
- `LocationContentLoader` parses a renderer-free `LocationDefinition`.
- `LocationGameState::setup()` creates or reconciles navigation, taxi, NPC,
interactive-object, trigger, teleport, and animation state.
- Saved mutable fields are overlaid on configuration defaults by stable ID.
- `Location::setup()` builds static and entity visuals from the prepared definition
without mutating authoritative state.
- The shared campaign player is attached.
- Runtime visuals are created for other player IDs currently present in the
snapshot and are subsequently spawned or removed as snapshots change.
- Dialogue, Lua, trigger/teleport visuals, and presentation callbacks are initialized.
- The runtime `Location` is owned and registered by `LocalGameServerHost`;
`InternalGameServer` remains headless.
### Location unloading
When leaving a location:
- Auxiliary runtime state is captured.
- The visual/runtime `Location` is destroyed.
- Its typed `LocationGameState` remains in `GameState`.
The player state remains campaign-level and survives location changes.
## Save and Load Schema
Authoritative persistence uses one strict, versioned schema. `schemaVersion` is
required and unsupported versions are rejected rather than migrated. A
single-player save is a separate versioned envelope containing `networkState`
(the authoritative `GameState` JSON) and `localState` (the client-local location
map). Lockstep sends that authoritative document only in `initial_state`; later
messages contain numbered command bundles.
- Campaign players are stored once in an ID-keyed `players` object.
- Locations are keyed by location ID.
- Each location stores NPC and interactive-object state in ID-keyed objects.
- Pending interaction targets and character references use stable IDs.
- Immutable content such as meshes, initial taxi configuration, and object
definitions is reconstructed from campaign configuration.
- Configuration is loaded first and mutable saved state is overlaid in place.
Snapshot-backed runtime objects then resolve the resulting state by stable ID.
Old user save slots are intentionally unsupported. Bundled start-state snapshots
must be regenerated with `Game::saveInitialState()` whenever the schema changes.
## UI, Scripts, Dialogue, and Quests
### UI
`MenuManager` no longer holds `GameState&` or a mutable `Inventory*`.
It now:
- Reads inventory, quests, tutorial state, chat, money, health, location, and world mode from `GameSnapshot`.
- Submits typed commands for mutations.
- Continues to own presentation-only UI trees, screen state, textures, and callbacks.
### Scripts
Lua remains an in-process single-player runtime. Character, inventory, quest, and
campaign-global bindings submit semantic commands through `Location`; the server
invokes operations on centrally owned state. Snapshot queries provide global
values, inventory counts, item presence, and synchronous quest validation for the
existing Lua API. NPC,
interactive-object, trigger-zone, and teleport-zone bindings address entities by
configured ID. Other object and global-state bindings remain trusted local
integrations.
Each location script registers initialization, enter, and exit callbacks.
Initialization runs only when the location has no retained state; loading the Lua
file again for rendering does not repeat state-resetting setup. Enter and exit
callbacks continue to run for every location transition.
The scalar Lua runtime state for each location is stored under `LocationGameState::scriptGlobals`. Multiplayer location scripts are expected to be empty, so remote script execution is intentionally outside the current scope.
### Dialogue and cutscenes
Dialogue runtime state is captured in each `LocationGameState`. Dialogue global
flags use snapshot getter and command setter callbacks rather than retaining a
mutable map pointer. Dialogue and cutscene rendering remain local presentation
systems.
Dialogues and cutscenes are outside the planned multiplayer gameplay mode, but their existing single-player save and callback behavior is retained.
### Quests, inventory, and tutorial
These callback-free state systems remain part of `GameState` for single-player
compatibility. UI access, mutations, and presentation notifications cross the
server interface. They can remain empty or disabled in multiplayer mode as planned.
## Multiplayer Lockstep
Protocol v2 uses server-sequenced deterministic lockstep:
- The server assigns a connection to a preconfigured campaign player slot.
- Connection receives one authoritative state at a completed tick plus its
assigned player ID, tick duration, input delay, and compatibility fingerprint.
- Destination input is normalized and scheduled two ticks in the future.
- The server broadcasts every numbered tick, including empty ticks. Clients
buffer out-of-order messages and advance only contiguous tick IDs.
- All replicas apply the same globally sequenced commands before one exact
100 ms update.
- Post-tick canonical hashes exclude persistence timestamps and local
presentation state. A mismatch is diagnosed but not automatically repaired.
No wall-clock timestamp participates in simulation. `steady_clock` paces the
server and local render accumulator only, so different machine clocks do not
affect command placement or state. The current missing-packet policy is to stall;
there is no prediction or rollback.
Current boundaries remain:
- only `SetCharacterDestinationCommand` crosses the network;
- player characters must already exist in the initial state and campaign slot
list; dynamic spawning and reconnect history are not implemented;
- authentication and automatic checkpoint resynchronization are absent;
- multiplayer Lua/dialogue shared behavior is unsupported;
- deterministic compatibility targets identical executable and content builds.
Floating-point simulation is not guaranteed bit-identical across platforms.
## Platform Build Integration
The new state and server files are registered in:
- `proj-windows/CMakeLists.txt`
- `proj-linux/CMakeLists.txt`
- `proj-web/CMakeLists.txt`
- `proj-android/app/jni/src/CMakeLists.txt`
The refactor uses portable C++17 and introduces no Windows-only gameplay code.
The standalone target lives under `server/`. It links the authoritative state,
campaign, content-definition, and portable resource sources only; SDL, OpenGL,
audio, Lua, dialogue presentation, and rendering sources are absent. It can prepare
a selected campaign/location with zero players. The Windows server adds
Boost.Beast transport and runs a fixed 100 ms lockstep loop; other platforms
continue to compile the portable state core without that transport.
The Windows Debug target was verified with:
```powershell
cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -- /m
```
## Important Constraints
- Runtime C++ callbacks are not part of `CharacterState` or public snapshots;
serializable Lua arrival function names are state data.
- `InteractiveObjectState` animation callbacks are also removed from public snapshots.
- Character target and attacker references use stable IDs (`"player"` or a unique
location-local NPC ID).
- Some top-level values remain mirrors for compatibility, notably player health used by the HUD and scripts.
- Dialogue and Lua are synchronized as auxiliary location state rather than fully networked subsystems.
- `IGameServerLifecycle` is the graphics-free campaign/location administration
boundary. `LocalGameServerHost` adds client-only runtime construction and
auxiliary-state synchronization without making `Game` a server friend.
- Empty player maps are valid. Player-specific interactions remain inactive until
a player entity is added.
- Pathfinding priority queues use cell index as a stable secondary key.
- Gameplay randomness must be seeded and serialized; the `random_device` battle
bot is disabled for multiplayer sessions.
- A lockstep compatibility fingerprint must match before the client accepts the
initial state.
## Main Files
- `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence.
- `src/LocationState.h` / `src/LocationState.cpp` — separate local camera and authoritative network location records, plus single-player persistence composition.
- `src/LocationGameState.h` / `src/LocationGameState.cpp` — authoritative typed location state, simulation, and ID-keyed persistence.
- `src/content/LocationDefinition.h` / `.cpp` — renderer-free content definitions,
parsing, and animation metadata loading.
- `src/game_server/IGameServer.h` — public snapshot and command contract.
- `src/game_server/IGameServerLifecycle.h` — graphics-free campaign/location lifecycle.
- `src/game_server/InternalGameServer.h` / `.cpp` — in-process authoritative implementation.
- `src/game_server/LocalGameServerHost.h` / `.cpp` — graphical-client runtime adapter.
- `src/game_server/NetworkProtocol.h` / `.cpp` — protocol-v2 messages,
lockstep timeline, validation, and canonical hashes.
- `src/game_server/WebsocketGameServer.h` / `.cpp` and
`WebsocketGameClient.h` / `.cpp` — slot assignment, input scheduling, tick
broadcasting, buffering, and replica advancement.
- `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring.
- `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing,
interactions, collisions, scripts, and presentation-event consumption.
- `src/Character.h` / `src/Character.cpp` — passive character presentation resolving
immutable `CharacterState` snapshots by stable ID.
- `src/CharacterState.h` / `src/CharacterState.cpp` — deterministic character
simulation, navigation operations, combat state, and logical animation clock.
- `src/items/InteractiveObject.h` / `.cpp` — visual object runtime bound to central object state.
- `src/items/GameObjectLoader.h` / `.cpp` — presentation builders for prepared definitions.
- `src/MenuManager.h` / `src/MenuManager.cpp` — snapshot-driven UI and command submission.
+176
View File
@@ -0,0 +1,176 @@
# WebSocket Deterministic Lockstep Networking
## Scope
The Windows WebSocket transport uses Boost.Asio and Boost.Beast to run a
server-sequenced deterministic lockstep simulation. A client receives one
authoritative state at a completed tick boundary. After that, the server sends
only numbered tick messages containing ordered player commands (or an empty
command list).
The current command surface is intentionally limited to
`SetCharacterDestinationCommand`.
## Runtime Architecture
### Server
`WebsocketGameServer` wraps the headless `InternalGameServer`.
- The simulation tick is fixed at 100 ms.
- Each accepted connection is assigned the first free character ID from the
campaign's `networkPlayerSlots`; the connection is rejected when all slots
are occupied.
- The initial message contains the state, completed snapshot tick, assigned
player ID, tick duration, two-tick input delay, and compatibility fingerprint.
- Destination inputs cross from the Asio thread through a queue. The simulation
thread validates client sequence numbers, replaces client identity with the
assigned slot, and schedules commands two ticks in the future.
- Every tick is broadcast, including ticks with no commands. No periodic state
snapshots are sent.
### Client
`WebsocketGameClient` implements `IGameServer`.
- It connects asynchronously to `ws://127.0.0.1:8080/`.
- Received messages are queued by the network thread and applied on the game
thread from `update()`.
- It maintains an internal `InternalGameServer` replica so existing snapshot
consumers and transitional runtime-state bindings continue to work.
- Camera orientation lives in host-owned `LocationLocalState`, so incoming
authoritative snapshots cannot overwrite camera movement.
- `submit()` serializes destination intent only. Other command variants remain
local no-ops for a remote connection.
- Tick bundles are buffered by ID. The replica advances only while the next
contiguous tick is available, applying its commands before one exact 100 ms
simulation step.
- Completed simulation events are passed to the runtime `Location` for
presentation, and ordinary server events are drainable through `IGameServer`.
- Client-side save and load calls are ignored because persistence remains
server-side.
`RemoteGameServerHost` prepares local presentation resources and runtime
locations against the replica and presents completed-tick events. It never
advances simulation from a render-frame delta.
## Campaign Routing
The graphical game uses stable router objects for `IGameServer` and
`IGameServerHost`, allowing existing UI and runtime references to remain valid.
When `NETWORK` is defined:
- `battle_zone_multiplayer` uses `WebsocketGameClient` and `RemoteGameServerHost`.
- `main`, `battle_zone`, and every other campaign use `InternalGameServer` and
`LocalGameServerHost`.
The WebSocket client is created only when `battle_zone_multiplayer` is selected
and is destroyed when that campaign is unloaded. The single-player and
multiplayer Battle Zone entries share location content and the start-state path,
so the state can be regenerated through the single-player entry. At startup, the
standalone server loads the authoritative half of that shared save and rewrites
its campaign ID to `battle_zone_multiplayer`; each remote client restores only
the local presentation half. The multiplayer campaign currently declares the
pre-existing `player` and `player2` state entries as connection
slots; the random test-bot controller is disabled.
The campaign's `networkCompatibility` token is the fingerprint; release builds
must bump it whenever deterministic content changes.
If DNS resolution, TCP connection, the WebSocket handshake, or the active socket
fails, the client unloads the remote campaign and returns to the campaign
selection screen.
## JSON Protocol v2
Every message uses a versioned JSON envelope:
```json
{
"protocolVersion": 2,
"type": "tick",
"payload": {
"tickId": 81,
"commands": [],
"stateHash": "0123456789abcdef"
}
}
```
Message types are:
- `initial_state` (server to client): one state snapshot and lockstep metadata;
- `destination_input` (client to server): monotonic client sequence and a
centimetre-normalized finite target;
- `tick` (server to client): tick ID, globally sequenced commands, and
post-tick diagnostic state hash;
- `error` (server to client): invalid input, incompatibility, or full server.
Protocol v1 is not compatible. Malformed payloads, non-finite or out-of-range
coordinates, duplicate client sequences, and unsupported commands cannot enter
the authoritative command stream.
WebSocket ordering is not used as simulation time. Integer tick IDs are the
only authoritative clock. The server uses `steady_clock` only to pace its loop,
and clients may have different wall clocks without affecting the result. A
client stalls on a missing tick instead of predicting or rolling back.
Single-player persistence remains a separate envelope combining authoritative
and client-local state. `savedAt`, camera state, and presentation objects are
excluded from the diagnostic deterministic hash.
## Build Configuration
Networking is enabled only for Windows:
- `proj-windows/CMakeLists.txt` defines `NETWORK`, compiles the WebSocket client
and remote host, and links `ws2_32` and `mswsock`.
- `server/CMakeLists.txt` defines `NETWORK` for the Windows `GameServer`, compiles
the WebSocket server, and links the Windows socket libraries.
- Linux, Android, and Web builds do not define `NETWORK` and do not compile the
network transport.
The previous Web-build `NETWORK` definition was removed.
## Running
Build the server:
```powershell
cmake -S server -B server/build
cmake --build server/build --config Debug --target GameServer
```
Run it from the repository root:
```powershell
.\server\build\Debug\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
```
The listener defaults to `0.0.0.0:8080`. It can be changed with:
```text
--address IP
--port N
```
Launch the Windows game and select `battle_zone_multiplayer`. The client connects to
`127.0.0.1:8080`. Selecting another campaign continues to use the local server.
## Verification
CTest covers protocol-v2 round trips and validation, input-delay ordering,
fixed-tick replica behavior, campaign slot metadata, initial connection and
slot assignment, destination submission, and contiguous tick consumption.
## Current Limitations
- Only preconfigured player slots can connect; dynamic join/spawn commands are
not implemented.
- There is no authentication or reconnect/replay history.
- A hash mismatch is logged but does not yet trigger checkpoint resynchronization.
- The endpoint is hardcoded in the graphical client.
- Multiplayer Lua and dialogue behavior remains unsupported.
- Determinism is supported for identical executable/content builds. The
floating-point simulation is not guaranteed bit-identical across platforms
or compilers.
+9
View File
@@ -96,6 +96,8 @@ add_library(main SHARED
../../../../src/Location.cpp
../../../../src/LocationState.h
../../../../src/LocationState.cpp
../../../../src/LocationGameState.h
../../../../src/LocationGameState.cpp
../../../../src/LocationEditor.h
../../../../src/LocationEditor.cpp
../../../../src/NpcCar.h
@@ -104,6 +106,11 @@ add_library(main SHARED
../../../../src/GameConstants.cpp
../../../../src/GameState.h
../../../../src/GameState.cpp
../../../../src/content/LocationDefinition.cpp
../../../../src/game_server/IGameServer.h
../../../../src/game_server/InternalGameServer.h
../../../../src/game_server/InternalGameServer.cpp
../../../../src/game_server/LocalGameServerHost.cpp
../../../../src/ScriptEngine.h
../../../../src/ScriptEngine.cpp
../../../../src/navigation/PathFinder.h
@@ -112,11 +119,13 @@ add_library(main SHARED
../../../../src/items/GameObjectLoader.cpp
../../../../src/items/Item.h
../../../../src/items/Item.cpp
../../../../src/items/Inventory.cpp
../../../../src/items/ItemRegistry.h
../../../../src/items/ItemRegistry.cpp
../../../../src/Campaign.cpp
../../../../src/items/InteractiveObject.h
../../../../src/items/InteractiveObject.cpp
../../../../src/items/InteractiveObjectState.cpp
../../../../src/items/InteractiveObjectState.h
../../../../src/dialogue/DialogueTypes.h
../../../../src/dialogue/DialogueDatabase.h
+13
View File
@@ -121,6 +121,8 @@ add_executable(ShadowOverBishkekDemo
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -129,6 +131,15 @@ add_executable(ShadowOverBishkekDemo
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.h
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/IGameServerLifecycle.h
../src/game_server/IGameServerHost.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.h
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -137,12 +148,14 @@ add_executable(ShadowOverBishkekDemo
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.h
../src/items/InteractiveObjectState.cpp
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
../src/dialogue/DialogueDatabase.cpp
+9 -1
View File
@@ -115,6 +115,8 @@ set(SOURCES
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -123,6 +125,11 @@ set(SOURCES
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -131,11 +138,13 @@ set(SOURCES
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.cpp
../src/items/InteractiveObjectState.h
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
@@ -193,7 +202,6 @@ set(EMSCRIPTEN_FLAGS
#"-pthread"
#"-sUSE_PTHREADS=1"
"-fexceptions"
"-DNETWORK"
)
target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2")
+27 -1
View File
@@ -63,6 +63,8 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -71,6 +73,17 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/content/LocationDefinition.h
../src/content/LocationDefinition.cpp
../src/game_server/IGameServer.h
../src/game_server/IGameServerLifecycle.h
../src/game_server/IGameServerHost.h
../src/game_server/GameServerRouter.h
../src/game_server/GameServerHostRouter.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/game_server/LocalGameServerHost.h
../src/game_server/LocalGameServerHost.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -79,12 +92,14 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/items/GameObjectLoader.cpp
../src/items/Item.h
../src/items/Item.cpp
../src/items/Inventory.cpp
../src/items/ItemRegistry.h
../src/items/ItemRegistry.cpp
../src/Campaign.cpp
../src/items/InteractiveObject.h
../src/items/InteractiveObject.cpp
../src/items/InteractiveObjectState.h
../src/items/InteractiveObjectState.cpp
../src/dialogue/DialogueTypes.h
../src/dialogue/DialogueDatabase.h
../src/dialogue/DialogueDatabase.cpp
@@ -125,7 +140,9 @@ set_target_properties(ShadowOverBishkekDemo PROPERTIES
# Определения препроцессора
target_compile_definitions(ShadowOverBishkekDemo PRIVATE
WIN32_LEAN_AND_MEAN
_WIN32_WINNT=0x0A00
PNG_ENABLED
NETWORK
)
# Линкуем с SDL2main, если он вообще установлен
@@ -147,7 +164,16 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
# Линкуем OpenGL (Windows)
if(WIN32)
target_link_libraries(ShadowOverBishkekDemo PRIVATE opengl32)
target_sources(ShadowOverBishkekDemo PRIVATE
../src/game_server/NetworkProtocol.h
../src/game_server/NetworkProtocol.cpp
../src/game_server/WebsocketGameClient.h
../src/game_server/WebsocketGameClient.cpp
../src/game_server/RemoteGameServerHost.h
../src/game_server/RemoteGameServerHost.cpp
)
target_link_libraries(
ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock)
endif()
# ===========================================
+16 -1
View File
@@ -17,7 +17,7 @@
},
{
"id": "battle_zone",
"title": { "ru": "Зона боя", "en": "Battle Zone" },
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" },
"locations": [
"resources/config/locations/battle_zone_uni_exterior.json"
],
@@ -27,6 +27,21 @@
"quests": "resources/config/quests_battle_zone.json",
"tutorial": false,
"loadingScreen": "resources/w/ui/screen_loading.json"
},
{
"id": "battle_zone_multiplayer",
"title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" },
"locations": [
"resources/config/locations/battle_zone_uni_exterior.json"
],
"startLocation": "uni_exterior",
"startMusic": "music/bishkek univer day.ogg",
"startState": "resources/config/start_state_battle_zone.json",
"quests": "resources/config/quests_battle_zone.json",
"tutorial": false,
"networkPlayerSlots": ["player", "player2"],
"networkCompatibility": "battle-zone-lockstep-v2",
"loadingScreen": "resources/w/ui/screen_loading.json"
}
]
}
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "WashingMachine002",
"name": "WashingMachine002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -27,6 +28,7 @@
"activateFunction": "on_washing_machine1_click"
},
{
"id": "Dryer002",
"name": "Dryer002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -53,6 +55,7 @@
"activateFunction": "on_dryer1_click"
},
{
"id": "Toilet001",
"name": "Toilet001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -79,6 +82,7 @@
"activateFunction": "on_toilet_click"
},
{
"id": "Sink002",
"name": "Sink002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -105,6 +109,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink003",
"name": "Sink003",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -131,6 +136,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink004",
"name": "Sink004",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -157,6 +163,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink005",
"name": "Sink005",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -183,6 +190,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Shower001",
"name": "Shower001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -209,6 +217,7 @@
"activateFunction": "on_shower_click"
},
{
"id": "Freezer001",
"name": "Freezer001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -235,6 +244,7 @@
"activateFunction": "on_freezer_click"
},
{
"id": "Boombox001",
"name": "Boombox001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -261,6 +271,7 @@
"activateFunction": "on_boombox_click"
},
{
"id": "WashingMachine001",
"name": "WashingMachine001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -287,6 +298,7 @@
"activateFunction": "on_washing_machine1_click"
},
{
"id": "Shelf001",
"name": "Shelf001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -313,6 +325,7 @@
"activateFunction": "on_shelf_click"
},
{
"id": "Stove001",
"name": "Stove001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -339,6 +352,7 @@
"activateFunction": "on_stove1_click"
},
{
"id": "Dryer001",
"name": "Dryer001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -365,6 +379,7 @@
"activateFunction": "on_dryer1_click"
},
{
"id": "Sink001",
"name": "Sink001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -391,6 +406,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Door_Room_1_1_0_Leaf_001",
"name": "Door_Room_1_1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_1_0_Leaf_001.txt.bin",
@@ -398,7 +414,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 10.0,
"approachRadius": 0.89,
"boundsMaxX": 0.01552116870880127,
@@ -415,6 +431,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_-1_0_Leaf_001",
"name": "Door_Room_1_-1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_-1_0_Leaf_001.txt.bin",
@@ -422,7 +439,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.10440409183502197,
@@ -439,6 +456,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_-1_1_0_Leaf_001",
"name": "Door_Room_-1_1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_1_0_Leaf_001.txt.bin",
@@ -446,7 +464,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.05766594409942627,
@@ -463,6 +481,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_-1_-1_0_Leaf_001",
"name": "Door_Room_-1_-1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_0_Leaf_001.txt.bin",
@@ -470,7 +489,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -10.0,
"approachRadius": 0.8,
"boundsMaxX": 0.10906755924224854,
@@ -487,6 +506,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_1_1_Leaf_001",
"name": "Door_Room_1_1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_1_1_Leaf_001.txt.bin",
@@ -494,7 +514,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 15.7,
"approachRadius": 0.8,
"boundsMaxX": 0.046350955963134766,
@@ -511,6 +531,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_-1_1_Leaf_001",
"name": "Door_Room_1_-1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_-1_1_Leaf_001.txt.bin",
@@ -518,7 +539,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 15.7,
"approachRadius": 0.800000011920929,
"boundsMaxX": 0.12420153617858887,
@@ -535,6 +556,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_1_1_2_Leaf_001",
"name": "Door_Utility_1_1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_1_1_2_Leaf_001.txt.bin",
@@ -542,7 +564,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 19.95,
"approachRadius": 0.8,
"boundsMaxX": -0.050987482070922852,
@@ -559,6 +581,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_1_-1_2_Leaf_001",
"name": "Door_Utility_1_-1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_1_-1_2_Leaf_001.txt.bin",
@@ -566,7 +589,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": 19.95,
"approachRadius": 0.8,
"boundsMaxX": 0.073349356651306152,
@@ -583,6 +606,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_-1_1_2_Leaf_001",
"name": "Door_Utility_-1_1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_-1_1_2_Leaf_001.txt.bin",
@@ -590,7 +614,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -19.95,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -599,6 +623,7 @@
"interactionRadius": 0.0
},
{
"id": "Door_Utility_-1_-1_2_Leaf_001",
"name": "Door_Utility_-1_-1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_-1_-1_2_Leaf_001.txt.bin",
@@ -606,7 +631,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -19.95,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -615,13 +640,14 @@
"interactionRadius": 0.0
},
{
"id": "Phone001",
"name": "Phone001",
"texturePath": "resources/w/dorm2/phone001_tex001.png",
"meshPath": "resources/w/dorm2/phone001_001.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 6.6644,
"positionX": 6.6644004991889,
"positionY": 0.9,
"positionZ": -12.5262,
"approachRadius": 0.6,
@@ -640,6 +666,7 @@
"activateFunction": "on_phone_pickup"
},
{
"id": "Journal001",
"name": "Journal001",
"texturePath": "resources/w/dorm2/Book_Cover_tex002.png",
"meshPath": "resources/w/dorm2/journal002_002.txt.bin",
@@ -647,7 +674,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.84296,
"positionY": 0.9,
"positionY": 0.9284919999539852,
"positionZ": -12.4661,
"approachRadius": 0.6,
"boundsMaxX": 0.17081403732299805,
@@ -665,6 +692,7 @@
"activateFunction": "on_journal_pickup"
},
{
"id": "Bed001",
"name": "Bed001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -691,21 +719,23 @@
"activateFunction": "on_bed_sleep"
},
{
"id": "Music001",
"name": "Music001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/dorm2/D_Music_004.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.10067,
"positionY": 0.91385,
"positionZ": -11.9566,
"positionX": 5.1006695008111,
"positionY": 1.0181855017900467,
"positionZ": -11.958324999397993,
"approachRadius": 0.6,
"scale": 1.0,
"interactionRadius": 0.3,
"activateFunction": ""
},
{
"id": "Door_Bath_-1_1_1_Leaf_002",
"name": "Door_Bath_-1_1_1_Leaf_002",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Bath_-1_1_1_Leaf_002.txt.bin",
@@ -713,7 +743,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 2.825,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -14.3,
"pivotX": -0.6,
"pivotY": 0.0,
@@ -733,6 +763,7 @@
"activateFunction": "on_player_bathroom_click"
},
{
"id": "Door_Bath_-1_-1_1_Leaf_002",
"name": "Door_Bath_-1_-1_1_Leaf_002",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Bath_-1_-1_1_Leaf_002.txt.bin",
@@ -740,7 +771,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -2.825,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -14.3,
"approachRadius": 0.8,
"boundsMaxX": 0.55439233779907227,
@@ -757,6 +788,7 @@
"activateFunction": "on_alik_bathroom_click"
},
{
"id": "Door_Room_-1_1_1_Leaf_001",
"name": "Door_Room_-1_1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_1_1_Leaf_001.txt.bin",
@@ -764,7 +796,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -15.7,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -784,6 +816,7 @@
"activateFunction": "on_player_door_click"
},
{
"id": "Door_Room_-1_-1_1_Leaf_001",
"name": "Door_Room_-1_-1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_1_Leaf_001.txt.bin",
@@ -791,7 +824,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -1.1,
"positionY": 0.0,
"positionY": 0.9695000052452087,
"positionZ": -15.7,
"approachRadius": 0.8,
"boundsMaxX": 0.13942182064056396,
@@ -811,6 +844,7 @@
"activateFunction": "on_alik_door_click"
},
{
"id": "Room_Cover_Bath_W_N_2_001",
"name": "Room_Cover_Bath_W_N_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Bath_W_N_2_002.txt.bin",
@@ -818,13 +852,14 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 2.8249999284744263,
"positionY": 2.8500000476837157,
"positionZ": -12.849999904632568,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_LivingRoom_W_N_2_001",
"name": "Room_Cover_LivingRoom_W_N_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_N_2_002.txt.bin",
@@ -832,13 +867,14 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -14.25,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_LivingRoom_W_S_2_001",
"name": "Room_Cover_LivingRoom_W_S_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_S_2_002.txt.bin",
@@ -846,13 +882,14 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": -4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -14.25,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Main_Hall_And_Corridors_002",
"name": "Room_Cover_Main_Hall_And_Corridors_002",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Main_Hall_And_Corridors_002.txt.bin",
@@ -861,12 +898,13 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionY": 2.8500000476837157,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Others_001",
"name": "Room_Cover_Others_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_All_002.txt.bin",
@@ -875,12 +913,13 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionY": 2.8500000476837157,
"positionZ": 4.250000476837158,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Utility_W_N_3_001",
"name": "Room_Cover_Utility_W_N_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_N_3_002.txt.bin",
@@ -888,13 +927,14 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": 4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -19.950000762939453,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Utility_W_S_3_001",
"name": "Room_Cover_Utility_W_S_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_S_3_002.txt.bin",
@@ -902,13 +942,14 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -0.1,
"positionZ": 0.0,
"positionX": -4.949999928474426,
"positionY": 2.8500000476837157,
"positionZ": -19.950000762939453,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "Roof",
"name": "Roof",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Shadow_Roof001_002.txt.bin",
@@ -919,7 +960,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.1,
"positionY": 3.2050000190734864,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
@@ -1,15 +1,16 @@
{
"objects": [
{
"id": "Container001",
"name": "Container001",
"texturePath": "resources/w/exterior/derbis001_tex001.png",
"meshPath": "resources/w/exterior/Derbis_001.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -26.8049,
"positionY": 0.0,
"positionZ": 9.11524,
"positionX": -27.003390023612976,
"positionY": 0.32889999728649855,
"positionZ": 8.820399544944763,
"approachRadius": 1.5,
"boundsMaxX": 2.9518299102783203,
"boundsMaxY": 0.5,
@@ -25,15 +26,16 @@
"activateFunction": "on_container_clicked"
},
{
"id": "car001",
"name": "car001",
"texturePath": "resources/e/car4096blue_v002.png",
"meshPath": "resources/e/car004.txt.bin",
"rotationX": 0.0,
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": 0.55,
"positionZ": -14.4195,
"positionX": -10.503284412622452,
"positionY": 0.9012498408555984,
"positionZ": -14.861633367061614,
"approachRadius": 0.5,
"boundsMaxX": 0.8858480453491211,
"boundsMaxY": 0.7,
@@ -49,6 +51,7 @@
"scale": 0.7
},
{
"id": "car001wheels",
"name": "car001wheels",
"texturePath": "resources/w/exterior/Wheel_base003.png",
"meshPath": "resources/w/exterior/wheels002.txt.bin",
@@ -56,8 +59,8 @@
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": 0.0,
"positionZ": -14.4195,
"positionY": 0.3359999924898147,
"positionZ": -14.839499933242797,
"approachRadius": 0.5,
"boundsMaxX": 0,
"boundsMaxY": 0.0,
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "Bookshelf002",
"name": "Bookshelf002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -27,6 +28,7 @@
"castShadow": false
},
{
"id": "Bookshelf003",
"name": "Bookshelf003",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -53,6 +55,7 @@
"castShadow": false
},
{
"id": "Bookshelf004",
"name": "Bookshelf004",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -79,6 +82,7 @@
"castShadow": false
},
{
"id": "Bookshelf005",
"name": "Bookshelf005",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -105,15 +109,16 @@
"castShadow": false
},
{
"id": "Cake001",
"name": "Cake001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/interior/D_Cake_005.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0,
"positionY": -5.0,
"positionZ": 0,
"positionX": -5.365539073944092,
"positionY": 0.9888315200805664,
"positionZ": 2.468366503715515,
"approachRadius": 0.6,
"boundsMaxX": 0.19605731964111328,
"boundsMaxY": 0.4,
@@ -131,6 +136,7 @@
"castShadow": false
},
{
"id": "Computer001",
"name": "Computer001",
"texturePath": "resources/w/interior/computer_texture002.png",
"textureDarkandsPath": "resources/w/interior/computer_texture002_dark.png",
@@ -138,9 +144,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 5.0916,
"positionY": 1.1534,
"positionZ": 0.751629,
"positionX": 5.01753252658844,
"positionY": 1.353411484324932,
"positionZ": 0.6829135021486282,
"approachRadius": 1.2,
"boundsMaxX": 0.6253643035888672,
"boundsMaxY": 0.5,
@@ -155,15 +161,16 @@
"activateFunction": "on_computer_clicked"
},
{
"id": "Knife001",
"name": "Knife001",
"texturePath": "resources/w/gg/Props.png",
"meshPath": "resources/w/interior/Knife_lay001_004.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": -4.8327,
"positionX": -4.959765496635623,
"positionY": 0.93551,
"positionZ": 2.2418,
"positionZ": 2.369899506953731,
"approachRadius": 1.4,
"boundsMaxX": 0.30867958068847656,
"boundsMaxY": 0.4,
@@ -177,6 +184,7 @@
"castShadowNight" : false
},
{
"id": "Book001",
"name": "Book001",
"texturePath": "resources/w/interior/Book_Cover_tex003.png",
"meshPath": "resources/w/interior/book002_002.txt.bin",
@@ -184,7 +192,7 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 1.84795,
"positionY": 0.6595,
"positionY": 0.6809705001562834,
"positionZ": 2.90552,
"approachRadius": 0.6,
"boundsMaxX": 0.2350165843963623,
@@ -202,6 +210,7 @@
"castShadowNight" : false
},
{
"id": "Bookshelf001",
"name": "Bookshelf001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -228,6 +237,7 @@
"castShadow": false
},
{
"id": "ReportCard001",
"name": "ReportCard001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/interior/D_report_cardGG_005.txt.bin",
@@ -252,6 +262,7 @@
"activateFunction": "on_report_card_pickup"
},
{
"id": "Note001",
"name": "Note001",
"texturePath": "resources/w/interior/note001_tex003.png",
"meshPath": "resources/w/interior/note001_002.txt.bin",
@@ -276,15 +287,16 @@
"activateFunction": "on_note_pickup"
},
{
"id": "car001",
"name": "car001",
"texturePath": "resources/e/car4096blue_v002.png",
"meshPath": "resources/e/car004.txt.bin",
"rotationX": 0.0,
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": -5.0,
"positionZ": -14.4195,
"positionX": -10.503284412622452,
"positionY": -4.648750159144401,
"positionZ": -14.861633367061614,
"approachRadius": 0.6,
"boundsMaxX": 0.0,
"boundsMaxY": 0.0,
@@ -299,6 +311,7 @@
"scale": 0.7
},
{
"id": "car001wheels",
"name": "car001wheels",
"texturePath": "resources/w/exterior/Wheel_base003.png",
"meshPath": "resources/w/exterior/wheels002.txt.bin",
@@ -306,8 +319,8 @@
"rotationY": 90.0,
"rotationZ": 0.0,
"positionX": -10.5,
"positionY": -5.0,
"positionZ": -14.4195,
"positionY": -4.664000007510185,
"positionZ": -14.839499933242797,
"approachRadius": 0.6,
"boundsMaxX": 0.0,
"boundsMaxY": 0.0,
@@ -322,6 +335,7 @@
"scale": 0.7
},
{
"id": "Room_N_2_Leaf001",
"name": "Room_N_2_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_N_2_Leaf001.txt.bin",
@@ -330,7 +344,7 @@
"rotationZ": 0.0,
"positionX": 1.5,
"positionY": 0.975,
"positionZ": 6.965,
"positionZ": 6.400000002384186,
"approachRadius": 0.8,
"boundsMaxX": 0.06785714626312256,
"boundsMaxY": 1.0,
@@ -349,6 +363,7 @@
"activateFunction": "on_library_door_click"
},
{
"id": "Room_N_1_Leaf001",
"name": "Room_N_1_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_N_1_Leaf001.txt.bin",
@@ -357,7 +372,7 @@
"rotationZ": 0.0,
"positionX": 1.5,
"positionY": 0.975,
"positionZ": -1.035,
"positionZ": -1.5999999976158141,
"approachRadius": 0.8,
"boundsMaxX": 0.10772454738616943,
"boundsMaxY": 1.0,
@@ -376,6 +391,7 @@
"activateFunction": "on_n2_door_click"
},
{
"id": "Room_S_1_Leaf001",
"name": "Room_S_1_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_1_Leaf001.txt.bin",
@@ -384,7 +400,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": -1.035,
"positionZ": -1.5999999976158141,
"approachRadius": 0.8,
"boundsMaxX": 0.12115073204040527,
"boundsMaxY": 1.0,
@@ -403,6 +419,7 @@
"activateFunction": "on_s2_door_click"
},
{
"id": "Room_S_2_Leaf001",
"name": "Room_S_2_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_2_Leaf001.txt.bin",
@@ -411,7 +428,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": 6.965,
"positionZ": 6.400000002384186,
"approachRadius": 0.8,
"boundsMaxX": 0.09747374057769775,
"boundsMaxY": 1.0,
@@ -430,15 +447,16 @@
"activateFunction": "on_teachers_door_click"
},
{
"id": "Hall_Leaf001",
"name": "Hall_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputHall_Leaf001.txt.bin",
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.565,
"positionX": 0.0,
"positionY": 0.975,
"positionZ": 8,
"positionZ": 8.000001000240445,
"approachRadius": 0.9,
"boundsMaxX": 0.6243956089019775,
"boundsMaxY": 1.0,
@@ -457,6 +475,7 @@
"activateFunction": "on_hall_door_click"
},
{
"id": "Room_S_0_Leaf001",
"name": "Room_S_0_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_0_Leaf001.txt.bin",
@@ -465,7 +484,7 @@
"rotationZ": 0.0,
"positionX": -1.5,
"positionY": 0.975,
"positionZ": -9.035,
"positionZ": -9.599999020778657,
"approachRadius": 0.8,
"boundsMaxX": 0.10914039611816406,
"boundsMaxY": 1.0,
@@ -484,6 +503,7 @@
"activateFunction": "on_s1_door_click"
},
{
"id": "Room_Cover_Corridor_001",
"name": "Room_Cover_Corridor_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_Corridor_003.txt.bin",
@@ -491,9 +511,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 2.2500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -3.999999761581421,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -501,6 +521,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Main_Hall_001",
"name": "Room_Cover_Main_Hall_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_Main_Hall_003.txt.bin",
@@ -509,8 +530,8 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionY": 2.4499998092651367,
"positionZ": 12.0,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -518,6 +539,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_1_001",
"name": "Room_Cover_South_1_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_1_003.txt.bin",
@@ -525,9 +547,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -12.0,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -535,6 +557,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_North_2_001",
"name": "Room_Cover_North_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_North_2_003.txt.bin",
@@ -542,9 +565,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -552,6 +575,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_2_001",
"name": "Room_Cover_South_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_2_003.txt.bin",
@@ -559,9 +583,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": -4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -569,6 +593,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_North_3_001",
"name": "Room_Cover_North_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_North_3_003.txt.bin",
@@ -576,9 +601,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": 3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": 4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
@@ -586,6 +611,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_3_001",
"name": "Room_Cover_South_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_3_003.txt.bin",
@@ -593,9 +619,9 @@
"rotationX": 0.0,
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": -5.0,
"positionZ": 0.0,
"positionX": -3.7500000596046448,
"positionY": 2.4499998092651367,
"positionZ": 4.000000048428774,
"scale": 1.0,
"pivotX": 0.0,
"pivotY": 0.0,
+1 -1
View File
@@ -145,7 +145,7 @@
"canAttack": true
},
{
"id": "npc_02_woman",
"id": "npc_altynai",
"name": "Алтынай",
"animationIdlePath": "resources/w/girl/girl_idle001_small.anim",
"animationWalkPath": "resources/w/girl/girl_walk010_small.anim",
File diff suppressed because it is too large. Load diff
+222 -95
View File
@@ -1,88 +1,169 @@
{
"campaignId": "battle_zone",
"chatHistory": [
[],
[],
[]
],
"chatPreviewMsg": [
"",
"",
""
],
"chatUnread": [
true,
true,
true
],
"currentLocationName": "uni_exterior",
"globalFloats": {
"player_hp": 200.0
},
"globalInts": {},
"inventory": {
"items": []
},
"isDarklands": false,
"isDawn": false,
"isNight": false,
"locations": {
"uni_exterior": {
"dialogueState": {
"active": false
},
"interactiveObjectStates": [],
"locationState": {
"activeNavigationIndex": 0,
"localState": {
"locations": {
"uni_exterior": {
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002,
"isDarklands": false,
"isDawn": false,
"isNight": false,
"targetInteractNpcIndex": -1,
"targetInteractiveObjectIndex": -1,
"targetTeleportZoneIndex": -1,
"taxi": {
"taxiArrivedCallbackCalled": false,
"taxiCar": {
"currentWaypoint": 0,
"mode": 1,
"positionX": -100.0,
"positionY": -100.0,
"positionZ": -100.0,
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypointReachRadius": 3.0,
"waypoints": []
"cameraInclination": 1.103600025177002
}
}
},
"networkState": {
"campaignId": "battle_zone",
"chatHistory": [
[],
[],
[]
],
"chatPreviewMsg": [
"",
"",
""
],
"chatUnread": [
true,
true,
true
],
"currentLocationId": "",
"globalFloats": {
"player_hp": 200.0
},
"globalInts": {},
"inventory": {
"items": []
},
"isDarklands": false,
"isDawn": false,
"isNight": false,
"locations": {
"uni_exterior": {
"dialogue": {
"active": false
},
"interactiveObjects": {
"intBin_Cans": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.6000000238418579,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"taxiCarInitialState": {
"currentWaypoint": 0,
"mode": 1,
"positionX": -100.0,
"positionY": -100.0,
"positionZ": -100.0,
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypointReachRadius": 3.0,
"waypoints": []
"intBin_Plastic": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 1.399999976158142,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"taxiCarTriggerPositionX": 0.0,
"taxiCarTriggerPositionY": 0.0,
"taxiCarTriggerPositionZ": 0.0,
"taxiDefaultWaypoints": []
}
},
"npcStates": [],
"playerState": {
"attackTargetIndex": -1,
"intBin_Waste_A": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 2.200000047683716,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"intBin_Waste_B": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 3.0,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"intRoof": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 3.200000047683716,
"positionZ": 0.0,
"rotationY": 0.0,
"scale": 1.0
},
"intVending.00": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": -1.0,
"positionY": 0.9150000214576721,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
},
"intVending.01": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 0.9150000214576721,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
},
"intVending.02": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 1.0,
"positionY": 0.9150000214576721,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
}
},
"npcs": {},
"scriptGlobals": {},
"state": {
"activeNavigationIndex": 0,
"targetInteractNpcId": "",
"targetInteractiveObjectId": "",
"targetTeleportZoneId": "",
"taxi": {
"arrivedCallbackCalled": false,
"car": {
"currentWaypoint": 0,
"mode": 1,
"position": [
-100.0,
-100.0,
-100.0
],
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypoints": [],
"wheelAngle": 0.0
}
}
},
"teleportZones": {},
"triggerZones": {}
}
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetIndex": -1,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
@@ -93,33 +174,79 @@
"hp": 200.0,
"pathWaypoints": [],
"position": [
-6, 0, 12.8
0.0,
0.0,
0.0
],
"requestedWalkTarget": [
-6, 0, 12.8
0.0,
0.0,
0.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6, 0, 12.8
0.0,
0.0,
0.0
]
},
"scriptGlobals": null,
"teleportZoneState": {},
"triggerZoneState": {}
}
"player": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.025001525878906,
0.0,
12.775001525878906
]
],
"position": [
-6.0,
0.0,
12.800000190734863
],
"requestedWalkTarget": [
-6.0,
0.0,
12.800000190734863
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.025001525878906,
0.0,
12.775001525878906
]
}
},
"questJournal": {
"quests": []
},
"savedAt": "2026-11-05 17:43",
"schemaVersion": 2,
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false,
"tutorialPhoneChatScreenOpened": false,
"tutorialStep": 0,
"tutorialTaxiHintDone": false
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"questJournal": {
"quests": []
},
"savedAt": "2026-07-24 23:40",
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false,
"tutorialPhoneChatScreenOpened": false,
"tutorialStep": 9
"persistenceVersion": 1
}
+4
View File
@@ -3,6 +3,9 @@
-- uni_exterior level with static objects and navigation only.
-- ============================================
function callback_init_location()
end
function callback_enter_location()
end
@@ -10,6 +13,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
+26 -27
View File
@@ -2,18 +2,8 @@
-- NPC PATROL WAYPOINTS
-- ============================================
bathroom_door_opened = false
player_door_opened = false
alik_door_opened = false
game_api.start_dialogue("dialog_start001")
phone_picked_up = false
journal_picked_up = false
function on_npc_interact(npc_index)
if npc_index == 0 then
function on_npc_interact(npc_id)
if npc_id == "npc_alik" then
--[[
local player_alik_aware = game_api.getIntValue("alik_aware")
local player_container_aware = game_api.getIntValue("player_container_aware")
@@ -25,7 +15,7 @@ function on_npc_interact(npc_index)
else
game_api.start_dialogue("dialog_alik001")
end]]
game_api.player_stop_and_rotate_to_npc(0)
game_api.player_stop_and_rotate_to_npc("npc_alik")
game_api.start_dialogue("dialog_alik002")
end
end
@@ -119,8 +109,8 @@ end
function callback_deactivate_cover_alik_room()
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
game_api.set_npc_enabled(0, true)
game_api.npc_rotate_to(0, 90)
game_api.set_npc_enabled("npc_alik", true)
game_api.npc_rotate_to("npc_alik", 90)
end
function callback_deactivate_cover_bathroom()
@@ -161,7 +151,7 @@ function closeAlikDoor()
game_api.activate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
game_api.set_object_alpha("Room_Cover_LivingRoom_W_S_2_001", 1)
game_api.set_object_rotation("Door_Room_-1_-1_1_Leaf_001", 0)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled("npc_alik", false)
if (bathroom_door_opened) then
game_api.switch_navigation(3) -- closed only alik (a)
else
@@ -330,11 +320,27 @@ function on_toilet_click()
game_api.player_stop()
end
function callback_init_location()
bathroom_door_opened = false
player_door_opened = false
alik_door_opened = false
phone_picked_up = false
journal_picked_up = false
game_api.start_dialogue("dialog_start001")
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_N_2_001")
game_api.rotate_object("Door_Utility_-1_1_2_Leaf_001", 90, 0.01, nil)
game_api.rotate_object("Door_Utility_-1_-1_2_Leaf_001", -90, 0.01, nil)
game_api.set_npc_enabled("npc_alik", false)
game_api.switch_navigation(0)
print("----callback_init_location----")
end
function callback_enter_location()
print("Enter location dorm")
local hp = game_api.getFloatValue("player_hp")
game_api.set_player_hp(hp)
game_api.set_teleport_active(0, false)
game_api.set_teleport_active("tp_dorm_to_loc2", false)
if (game_api.is_night()) then
closeAlikDoor()
end
@@ -347,25 +353,18 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
function callback_call_taxi()
game_api.set_teleport_active(0, true)
game_api.set_trigger_zone_enabled(0, false)
game_api.set_teleport_active("tp_dorm_to_loc2", true)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_N_2_001")
game_api.rotate_object("Door_Utility_-1_1_2_Leaf_001", 90, 0.01, nil)
game_api.rotate_object("Door_Utility_-1_-1_2_Leaf_001", -90, 0.01, nil)
game_api.set_call_taxi_callback(
callback_call_taxi
)
game_api.set_npc_enabled(0, false)
game_api.switch_navigation(0)
print("Lua script loaded successfully--!")
+8 -19
View File
@@ -1,16 +1,5 @@
print("Lua script loaded successfully!")
function callback_enter_location()
end
function callback_exit_location()
end
game_api.set_location_callbacks(
callback_enter_location,
callback_exit_location
)
function darklands_exit001_enter_callback()
if game_api.is_darklands() then
print("Lua script start_darklands_transition")
@@ -77,11 +66,14 @@ game_api.set_enter_night_callback(
)
function callback_init_location()
end
function callback_enter_location()
print("Enter location uni exterior")
local hp = game_api.getFloatValue("player_hp")
game_api.set_player_hp(hp)
game_api.set_teleport_active(0, false)
game_api.set_teleport_active("tp_loc2_to_dorm", false)
if (game_api.is_night()) then
game_api.deactivate_interactive_object("car001")
game_api.deactivate_interactive_object("car001wheels")
@@ -100,6 +92,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
@@ -108,10 +101,6 @@ game_api.set_trigger_zone_callbacks("darklands_exit001",
darklands_exit001_enter_callback,
nil
)
game_api.set_trigger_zone_callbacks("darklands_exit002",
darklands_exit001_enter_callback,
nil
)
game_api.set_trigger_zone_callbacks("darklands_exit003",
darklands_exit001_enter_callback,
nil
@@ -129,12 +118,12 @@ game_api.set_trigger_zone_callbacks("darklands_exit005",
function taxi_zone001_enter_callback()
game_api.start_dialogue("dialog_taxi001")
game_api.set_trigger_zone_enabled(0, false)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
function callback_call_taxi()
game_api.set_teleport_active(0, true)
game_api.set_trigger_zone_enabled(0, false)
game_api.set_teleport_active("tp_loc2_to_dorm", true)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
game_api.set_call_taxi_callback(
+129 -136
View File
@@ -1,14 +1,6 @@
CONST_MSG_EMPTY = "(пусто)"
CONST_MSG_WHERE = "Бекзат, ты где? Я жду..."
hall_door_opened = false
teacher_door_opened = false
player_hold_book = false
teacher_arrived_to_library = false
teacher_told_about_book = false
--[[
0 - day0
1 - darklands
@@ -17,14 +9,6 @@ teacher_told_about_book = false
4 - day
]]
was_state_when_player_left = 0
was_day_when_player_left = 0
morning_can_open_door_index = 0
morning_did_open_door_index = 0
ghost_gone = false
function dialog_aiperi_give_key()
local player_hold_key = game_api.getIntValue("player_hold_key")
@@ -65,7 +49,7 @@ function knife_dialog_zone001_enter_callback()
if (player_hold_knife == 0) then
local lection_is_over = game_api.getIntValue("lection_is_over")
if lection_is_over == 1 then
game_api.npc_stop_and_rotate_to_player(1)
game_api.npc_stop_and_rotate_to_player("npc_02_woman")
if (player_hold_key == 1) then
game_api.start_dialogue("knife_dialog002")
else
@@ -79,7 +63,7 @@ function knife_dialog_zone001_enter_callback()
if (aiperi_talked_after_knife == 0) then
game_api.start_dialogue("knife_dialog_second001")
end
game_api.set_trigger_zone_enabled(1, false)
game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
end
end
end
@@ -110,11 +94,11 @@ game_api.set_trigger_zone_callbacks("knife_dialog_zone001",
function on_knife_pickup()
game_api.pickup_item("knife")
game_api.deactivate_interactive_object("Knife001")
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.setIntValue("player_hold_knife", 1)
game_api.set_trigger_zone_enabled(2, true)
game_api.npc_walk_to(0, -4.57412, 0, 6.78495, on_teacher_arrived2)
game_api.set_trigger_zone_enabled("teacher_dialog_zone001", true)
game_api.npc_walk_to("npc_teacher01", -4.57412, 0, 6.78495, on_teacher_arrived2)
game_api.quest_set_objective_completed("aiperi_knife", "aiperi_knife_take")
game_api.player_stop()
end
@@ -133,14 +117,14 @@ function on_book_pickup()
player_hold_book = true
game_api.quest_set_objective_completed("study_project", "study_project_book")
game_api.start_dialogue("book_dialog003")
--game_api.set_trigger_zone_enabled(3, true)
--game_api.set_trigger_zone_enabled("book_dialog_zone001", true)
end
else
game_api.remove_item("book")
game_api.activate_interactive_object("Book001")
player_hold_book = false
game_api.start_dialogue("book_dialog004")
--game_api.set_trigger_zone_enabled(3, false)
--game_api.set_trigger_zone_enabled("book_dialog_zone001", false)
end
else
print("on_book_pickup step2")
@@ -218,14 +202,14 @@ function on_cake_clicked()
game_api.player_stop()
end
function on_npc_interact(npc_index)
function on_npc_interact(npc_id)
local player_ghost_aware = game_api.getIntValue("ghost_aware")
local player_hold_key = game_api.getIntValue("player_hold_key")
local day = game_api.getIntValue("day")
print("[Lua] NPC interaction! Index: " .. tostring(npc_index))
if npc_index == 1 then
game_api.player_stop_and_rotate_to_npc(1)
print("[Lua] NPC interaction! ID: " .. tostring(npc_id))
if npc_id == "npc_02_woman" then
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
if (game_api.is_dawn()) or (day > 0) then
game_api.start_dialogue("dialog_aiperi_morning001")
else
@@ -236,8 +220,8 @@ function on_npc_interact(npc_index)
end
end
end
if npc_index == 0 then
game_api.player_stop_and_rotate_to_npc(0)
if npc_id == "npc_teacher01" then
game_api.player_stop_and_rotate_to_npc("npc_teacher01")
if (day == 0) then
if (teacher_arrived_to_library) then
if (teacher_told_about_book) then
@@ -264,8 +248,8 @@ function on_npc_interact(npc_index)
end
end
end
if npc_index == 2 then
game_api.player_stop_and_rotate_to_npc(2)
if npc_id == "npc_04_ghost" then
game_api.player_stop_and_rotate_to_npc("npc_04_ghost")
local report_card_signed = game_api.getIntValue("report_card_signed")
if (report_card_signed == 1) then
game_api.start_dialogue("dialog_with_ghost003")
@@ -273,18 +257,18 @@ function on_npc_interact(npc_index)
game_api.start_dialogue("dialog_with_ghost001")
end
end
if npc_index == 6 then
game_api.player_stop_and_rotate_to_npc(6)
if npc_id == "npc_altynai" then
game_api.player_stop_and_rotate_to_npc("npc_altynai")
game_api.start_dialogue("dialog_female_student002")
end
end
function on_quest_over()
ghost_gone = true
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.quest_set_objective_completed("ghost_release", "ghost_release_show")
game_api.start_cutscene("game_complete_cutscene001")
@@ -297,7 +281,7 @@ end
function teacher_zone001_enter_callback()
game_api.start_dialogue("teacher_dialog002")
game_api.set_trigger_zone_enabled(2, false)
game_api.set_trigger_zone_enabled("teacher_dialog_zone001", false)
teacher_told_about_book = true
game_api.player_stop()
end
@@ -474,9 +458,9 @@ function on_hall_door_click()
game_api.switch_navigation(1)
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Main_Hall_001", 0, 0.5, callback_deactivate_cover_main_hall)
game_api.set_npc_enabled(0, true)
game_api.set_npc_enabled(6, true)
game_api.npc_rotate_to(6, 180)
game_api.set_npc_enabled("npc_teacher01", true)
game_api.set_npc_enabled("npc_altynai", true)
game_api.npc_rotate_to("npc_altynai", 180)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
game_api.player_stop()
end
@@ -558,22 +542,22 @@ function on_n2_door_click()
end
function on_teacher_arrived()
game_api.npc_rotate_to(0, 180)
game_api.npc_rotate_to("npc_teacher01", 180)
teacher_arrived_to_library = true
end
function on_student_arrived()
game_api.npc_rotate_to(6, 270)
game_api.npc_rotate_to("npc_altynai", 270)
end
function on_teacher_arrived2()
game_api.npc_rotate_to(0, 0)
game_api.npc_rotate_to("npc_teacher01", 0)
end
function book_dialog_zone001_enter_callback()
if (game_api.is_darklands()) then
game_api.start_dialogue("ghost_dialog002")
game_api.set_trigger_zone_enabled(3, false)
game_api.set_trigger_zone_enabled("book_dialog_zone001", false)
game_api.advance_darklands_hud()
else
if (player_hold_book) and (game_api.is_night() == false) then
@@ -621,9 +605,9 @@ function on_sleep_cutscene()
print("Cutscene 2 done!")
game_api.set_night()
--game_api.set_trigger_zone_enabled(1, false)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(6, false)
--game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.switch_navigation(16)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Hall_Leaf001", 0)
@@ -649,14 +633,14 @@ function on_teacher_arrived_intermediate()
game_api.rotate_object("Room_N_2_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_North_3_001", 0, 0.5, callback_deactivate_cover_n3)
game_api.switch_navigation(2)
game_api.npc_walk_to(0, 4.94194, 0, 6.64111, on_teacher_arrived)
game_api.npc_walk_to("npc_teacher01", 4.94194, 0, 6.64111, on_teacher_arrived)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
end
function on_lection_cutscene_fadein_callback()
print("Lection cutscene in progress!")
-- -0.0234149 0 10.1139
game_api.npc_set_position(0, 0.0, 0.0, 10.1139)
game_api.npc_set_position("npc_teacher01", 0.0, 0.0, 10.1139)
--4.91002 0 10.375
--game_api.player_set_position(-2.41002, 0, 10.375)
@@ -670,10 +654,10 @@ function callback_on_lection_cutscene_over()
print("Cutscene done!")
game_api.setIntValue("lection_is_over", 1)
game_api.set_npc_enabled(1, true)
game_api.npc_rotate_to(1, 180)
game_api.npc_walk_to(0, 0.758123, 0, 6.50063, on_teacher_arrived_intermediate)
game_api.npc_walk_to(6, 5.28998, 0, -5.525, on_student_arrived)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_rotate_to("npc_02_woman", 180)
game_api.npc_walk_to("npc_teacher01", 0.758123, 0, 6.50063, on_teacher_arrived_intermediate)
game_api.npc_walk_to("npc_altynai", 5.28998, 0, -5.525, on_student_arrived)
local aiperi_knife_aware = game_api.getIntValue("aiperi_knife_aware")
@@ -715,38 +699,36 @@ function on_bookshelf_teacher_clicked()
on_report_card_pickup()
end
first_time_darklands = true
function callback_enter_darklands()
if (first_time_darklands) then
game_api.start_dialogue("ghost_dialog001")
first_time_darklands = false
end
game_api.set_trigger_zone_enabled(1, false)
game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_altynai", false)
if (ghost_gone) then
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
else
game_api.set_npc_enabled(2, true)
game_api.set_npc_enabled(3, true)
game_api.set_npc_enabled(4, true)
game_api.set_npc_enabled(5, true)
game_api.set_npc_enabled("npc_04_ghost", true)
game_api.set_npc_enabled("ghost_01x", true)
game_api.set_npc_enabled("ghost_02x", true)
game_api.set_npc_enabled("ghost_03x", true)
game_api.npc_set_hp(3, 35)
game_api.npc_set_hp(4, 35)
game_api.npc_set_hp(5, 35)
game_api.npc_set_hp("ghost_01x", 35)
game_api.npc_set_hp("ghost_02x", 35)
game_api.npc_set_hp("ghost_03x", 35)
game_api.npc_set_position(3, 0, 0, -6.4)
game_api.npc_set_position(4, -5.0, 0, 12)
game_api.npc_set_position(5, 5.0, 0, 12)
game_api.npc_set_position("ghost_01x", 0, 0, -6.4)
game_api.npc_set_position("ghost_02x", -5.0, 0, 12)
game_api.npc_set_position("ghost_03x", 5.0, 0, 12)
end
game_api.switch_navigation(5)
@@ -755,13 +737,13 @@ end
function callback_exit_darklands()
game_api.start_cutscene("darklands_exit001")
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
morning_did_open_door_index = 0
setDay1MorningSetup()
@@ -873,13 +855,13 @@ function setDay0setup()
game_api.deactivate_interactive_object("Note001")
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.activate_interactive_object("car001")
game_api.activate_interactive_object("car001wheels")
@@ -903,7 +885,6 @@ function setDay1setup()
game_api.activate_interactive_object("Room_Cover_South_2_001")
game_api.deactivate_interactive_object("Room_Cover_South_3_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", -90)
game_api.set_object_rotation("Room_N_2_Leaf001", -90)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -911,15 +892,15 @@ function setDay1setup()
game_api.set_object_rotation("Room_S_2_Leaf001", 90)
game_api.set_object_rotation("Hall_Leaf001", 90)
game_api.set_npc_enabled(0, true)
game_api.set_npc_enabled(1, true)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, true)
game_api.set_npc_enabled("npc_teacher01", true)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", true)
game_api.npc_set_position(1, 0.98998, 0, -7.525)
game_api.npc_set_position("npc_02_woman", 0.98998, 0, -7.525)
game_api.deactivate_interactive_object("Cake001")
@@ -939,7 +920,6 @@ function setDay1MorningSetup()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
game_api.activate_interactive_object("Room_Cover_Main_Hall_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", 0)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -964,15 +944,15 @@ function setDay1MorningSetup()
-- TODO: Make check for teacher_door_opened false or true
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.npc_set_position(1, 0.98998, 0, -7.525)
game_api.npc_set_position("npc_02_woman", 0.98998, 0, -7.525)
if morning_did_open_door_index == 1 then
game_api.deactivate_interactive_object("Room_Cover_South_1_001")
@@ -1043,7 +1023,6 @@ function setDay1NightSetup()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
game_api.activate_interactive_object("Room_Cover_Main_Hall_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", 0)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -1059,13 +1038,13 @@ function setDay1NightSetup()
game_api.set_object_alpha("Room_Cover_Corridor_001", 1)
game_api.set_object_alpha("Room_Cover_Main_Hall_001", 1)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.deactivate_interactive_object("car001")
game_api.deactivate_interactive_object("car001wheels")
@@ -1222,8 +1201,8 @@ game_api.set_chat_callbacks(
)
function callback_aiperi_dialog_morning()
game_api.player_stop_and_rotate_to_npc(1)
game_api.npc_stop_and_rotate_to_player(1)
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
game_api.npc_stop_and_rotate_to_player("npc_02_woman")
game_api.start_dialogue("dialog_aiperi_morning001")
end
@@ -1246,9 +1225,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, 6.5)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 0.0, 0, 9.1, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, 6.5)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 0.0, 0, 9.1, callback_aiperi_dialog_morning)
game_api.player_walk_to(0.0, 0.0, 10.0, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1268,9 +1247,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0.0, 0, 6.43818)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.34349, 0, 6.31229, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0.0, 0, 6.43818)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 2.34349, 0, 6.31229, callback_aiperi_dialog_morning)
game_api.player_walk_to(3.52174, 0, 6.28993, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1290,9 +1269,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.11898, 0, -1.7074, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 2.11898, 0, -1.7074, callback_aiperi_dialog_morning)
game_api.player_walk_to(3.36055, 0, -2.0345, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1312,9 +1291,9 @@ function on_aiperi_opens_door()
game_api.switch_navigation(9)
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.454, 0, -1.61871, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", -2.454, 0, -1.61871, callback_aiperi_dialog_morning)
game_api.player_walk_to(-3.63363, 0, -1.50727, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1334,9 +1313,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -9.77948)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.53977, 0, -9.77852, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -9.77948)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", -2.53977, 0, -9.77852, callback_aiperi_dialog_morning)
game_api.player_walk_to(-3.64494, 0, -9.66711, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1345,7 +1324,24 @@ function on_aiperi_opens_door()
end
function on_aiperi_morning_dialog_finished()
game_api.npc_walk_to(1, 0.98998, 0, -7.525, nil)
game_api.npc_walk_to("npc_02_woman", 0.98998, 0, -7.525, nil)
end
function callback_init_location()
hall_door_opened = false
teacher_door_opened = false
player_hold_book = false
teacher_arrived_to_library = false
teacher_told_about_book = false
was_state_when_player_left = 0
was_day_when_player_left = 0
morning_can_open_door_index = 0
morning_did_open_door_index = 0
ghost_gone = false
first_time_darklands = true
setDay0setup()
game_api.switch_navigation(0)
end
function callback_enter_location()
@@ -1437,6 +1433,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
@@ -1467,9 +1464,5 @@ end
game_api.set_cutscene_callback("game_complete_cutscene001", callback_game_complete_cutscene)
setDay0setup()
game_api.switch_navigation(0)
--debugAllOpen()
print("Lua script loaded successfully!---!")
+16 -8
View File
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "intBin_Cans",
"name": "intBin_Cans",
"texturePath": "resources/w/shelter/models/intBin_Cans_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Cans.txt.bin",
@@ -8,12 +9,13 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 0.6,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intBin_Plastic",
"name": "intBin_Plastic",
"texturePath": "resources/w/shelter/models/intBin_Plastic_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Plastic.txt.bin",
@@ -21,12 +23,13 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 1.4,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intBin_Waste_A",
"name": "intBin_Waste_A",
"texturePath": "resources/w/shelter/models/intBin_Waste_A_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Waste_A.txt.bin",
@@ -34,12 +37,13 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 2.2,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intBin_Waste_B",
"name": "intBin_Waste_B",
"texturePath": "resources/w/shelter/models/intBin_Waste_B_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Waste_B.txt.bin",
@@ -47,12 +51,13 @@
"rotationY": -180.0,
"rotationZ": 0.0,
"positionX": 3.0,
"positionY": 0.0,
"positionY": 0.45736101269721985,
"positionZ": 3.85,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intRoof",
"name": "intRoof",
"texturePath": "resources/w/shelter/models/intRoof_atlas.png",
"meshPath": "resources/w/shelter/models/intRoof.txt",
@@ -60,12 +65,13 @@
"rotationY": 0.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.0,
"positionY": 3.2,
"positionZ": 0.0,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intVending.00",
"name": "intVending.00",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
@@ -73,12 +79,13 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": -1.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intVending.01",
"name": "intVending.01",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
@@ -86,12 +93,13 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": 0.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
},
{
"id": "intVending.02",
"name": "intVending.02",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
@@ -99,7 +107,7 @@
"rotationY": -90.0,
"rotationZ": 0.0,
"positionX": 1.0,
"positionY": 0.0,
"positionY": 0.915,
"positionZ": -3.9,
"scale": 1.0,
"interactionRadius": 0.0
+175
View File
@@ -0,0 +1,175 @@
cmake_minimum_required(VERSION 3.16)
project(GameServer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..")
find_package(Threads REQUIRED)
find_package(Eigen3 3.3 CONFIG QUIET)
if(NOT TARGET Eigen3::Eigen)
set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0")
if(NOT EXISTS "${EIGEN_BUNDLED_DIR}/Eigen/Core")
message(FATAL_ERROR
"Eigen3 was not found. Install Eigen3 or configure the graphical "
"project once to populate thirdparty/eigen-5.0.0.")
endif()
add_library(game_server_eigen INTERFACE)
target_include_directories(game_server_eigen INTERFACE "${EIGEN_BUNDLED_DIR}")
add_library(Eigen3::Eigen ALIAS game_server_eigen)
endif()
add_library(game_server_core STATIC
"${GAME_ROOT}/src/Campaign.cpp"
"${GAME_ROOT}/src/Campaign.h"
"${GAME_ROOT}/src/CharacterState.cpp"
"${GAME_ROOT}/src/CharacterState.h"
"${GAME_ROOT}/src/GameState.cpp"
"${GAME_ROOT}/src/GameState.h"
"${GAME_ROOT}/src/ISaveable.h"
"${GAME_ROOT}/src/Localization.cpp"
"${GAME_ROOT}/src/Localization.h"
"${GAME_ROOT}/src/LocationGameState.cpp"
"${GAME_ROOT}/src/LocationGameState.h"
"${GAME_ROOT}/src/LocationState.cpp"
"${GAME_ROOT}/src/LocationState.h"
"${GAME_ROOT}/src/NpcCar.cpp"
"${GAME_ROOT}/src/NpcCar.h"
"${GAME_ROOT}/src/content/LocationDefinition.cpp"
"${GAME_ROOT}/src/content/LocationDefinition.h"
"${GAME_ROOT}/src/game_server/IGameServer.h"
"${GAME_ROOT}/src/game_server/IGameServerLifecycle.h"
"${GAME_ROOT}/src/game_server/InternalGameServer.cpp"
"${GAME_ROOT}/src/game_server/InternalGameServer.h"
"${GAME_ROOT}/src/items/InteractiveObjectState.cpp"
"${GAME_ROOT}/src/items/InteractiveObjectState.h"
"${GAME_ROOT}/src/items/Inventory.cpp"
"${GAME_ROOT}/src/items/Item.h"
"${GAME_ROOT}/src/navigation/PathFinder.cpp"
"${GAME_ROOT}/src/navigation/PathFinder.h"
"${GAME_ROOT}/src/quest/QuestJournal.cpp"
"${GAME_ROOT}/src/quest/QuestJournal.h"
"${GAME_ROOT}/src/quest/QuestTypes.h"
"${GAME_ROOT}/src/utils/HeadlessUtils.cpp"
"${GAME_ROOT}/src/utils/Utils.h"
)
source_group(TREE "${GAME_ROOT}/src" PREFIX "Source" FILES
"${GAME_ROOT}/src/Campaign.cpp"
"${GAME_ROOT}/src/Campaign.h"
"${GAME_ROOT}/src/CharacterState.cpp"
"${GAME_ROOT}/src/CharacterState.h"
"${GAME_ROOT}/src/GameState.cpp"
"${GAME_ROOT}/src/GameState.h"
"${GAME_ROOT}/src/ISaveable.h"
"${GAME_ROOT}/src/Localization.cpp"
"${GAME_ROOT}/src/Localization.h"
"${GAME_ROOT}/src/LocationGameState.cpp"
"${GAME_ROOT}/src/LocationGameState.h"
"${GAME_ROOT}/src/LocationState.cpp"
"${GAME_ROOT}/src/LocationState.h"
"${GAME_ROOT}/src/NpcCar.cpp"
"${GAME_ROOT}/src/NpcCar.h"
"${GAME_ROOT}/src/content/LocationDefinition.cpp"
"${GAME_ROOT}/src/content/LocationDefinition.h"
"${GAME_ROOT}/src/game_server/IGameServer.h"
"${GAME_ROOT}/src/game_server/IGameServerLifecycle.h"
"${GAME_ROOT}/src/game_server/InternalGameServer.cpp"
"${GAME_ROOT}/src/game_server/InternalGameServer.h"
"${GAME_ROOT}/src/items/InteractiveObjectState.cpp"
"${GAME_ROOT}/src/items/InteractiveObjectState.h"
"${GAME_ROOT}/src/items/Inventory.cpp"
"${GAME_ROOT}/src/items/Item.h"
"${GAME_ROOT}/src/navigation/PathFinder.cpp"
"${GAME_ROOT}/src/navigation/PathFinder.h"
"${GAME_ROOT}/src/quest/QuestJournal.cpp"
"${GAME_ROOT}/src/quest/QuestJournal.h"
"${GAME_ROOT}/src/quest/QuestTypes.h"
"${GAME_ROOT}/src/utils/HeadlessUtils.cpp"
"${GAME_ROOT}/src/utils/Utils.h"
)
target_include_directories(game_server_core PUBLIC "${GAME_ROOT}/src")
target_link_libraries(game_server_core PUBLIC Eigen3::Eigen Threads::Threads)
target_compile_definitions(game_server_core PRIVATE FRG_HEADLESS_SERVER=1)
if(MSVC)
target_compile_options(game_server_core PRIVATE /W4 /permissive-)
else()
target_compile_options(game_server_core PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(GameServer src/main.cpp README.md)
source_group("Server" FILES src/main.cpp README.md)
target_link_libraries(GameServer PRIVATE game_server_core)
if(WIN32)
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
message(FATAL_ERROR
"Boost was not found at thirdparty/boost_1_90_0. Configure the "
"graphical project once to populate it.")
endif()
target_sources(GameServer PRIVATE
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/NetworkProtocol.h"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.h")
target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(
GameServer PRIVATE NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(GameServer PRIVATE ws2_32 mswsock)
endif()
include(CTest)
if(BUILD_TESTING)
add_executable(
GameServerStateTests tests/LocationGameStateTests.cpp)
target_link_libraries(GameServerStateTests PRIVATE game_server_core)
source_group(
"Server\\Tests" FILES tests/LocationGameStateTests.cpp)
add_test(
NAME GameServer.LocationState
COMMAND GameServerStateTests)
if(WIN32)
add_executable(
GameServerNetworkProtocolTests
tests/NetworkProtocolTests.cpp
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/NetworkProtocol.h")
target_link_libraries(
GameServerNetworkProtocolTests PRIVATE game_server_core)
add_test(
NAME GameServer.NetworkProtocol
COMMAND GameServerNetworkProtocolTests)
add_executable(
GameServerWebsocketLockstepTests
tests/WebsocketLockstepTests.cpp
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameClient.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp")
target_include_directories(
GameServerWebsocketLockstepTests PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
add_test(
NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests)
endif()
add_test(
NAME GameServer.IdleSmoke
COMMAND GameServer --ticks 2 --no-sleep)
add_test(
NAME GameServer.BattleZoneSharedPlayer
COMMAND GameServer --campaign battle_zone --location uni_exterior
--ticks 2 --no-sleep)
set_tests_properties(
GameServer.BattleZoneSharedPlayer
PROPERTIES WORKING_DIRECTORY "${GAME_ROOT}")
endif()
+104
View File
@@ -0,0 +1,104 @@
# Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
lockstep session through a Boost.Beast WebSocket listener.
## Build
Windows:
```powershell
cmake -S server -B server/build
cmake --build server/build --config Release --target GameServer
ctest --test-dir server/build -C Release --output-on-failure
```
Linux:
```bash
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
cmake --build server/build --target GameServer --parallel
ctest --test-dir server/build --output-on-failure
```
Eigen3 must either be installed or be available at
`thirdparty/eigen-5.0.0`. The headless target reads ordinary filesystem resources
only; ZIP-backed campaign loading belongs to the later content-loading
milestone.
Run until interrupted:
```bash
./server/build/GameServer
```
For a multi-configuration Windows build, the executable is normally
`server/build/Release/GameServer.exe`.
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay.
Start the multiplayer lockstep server:
```powershell
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
```
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
afterward and select `battle_zone_multiplayer`; that campaign connects to
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
The server sends one state snapshot when a player connects, then broadcasts one
numbered command bundle every 100 ms. Bundles may be empty. Destination input is
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
client wall-clock values, define simulation time.
If `--location` is omitted, the campaign's configured start location is used.
`--campaigns PATH` selects a different campaign registry. Content paths are
resolved from the process working directory, so run from the repository root
when using the bundled `resources/` tree.
Available startup options:
```text
--campaign ID
--location ID
--campaigns PATH
--address IP
--port N
--tick-ms 100
--ticks N
--no-sleep
```
Network mode requires a 100 ms tick; another `--tick-ms` value is rejected.
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs
assigned to connections in order. `networkCompatibility` is a release token
that must be changed whenever deterministic content changes; clients reject a
different token. The bundled multiplayer Battle Zone has two slots (`player`
and `player2`) and rejects additional clients. Disconnecting
releases the slot. The bot's random autonomous controller is disabled in
multiplayer.
The graphical client executes presentation behavior after building its runtime
`Location`; the headless content pass loads authoritative navigation, NPC,
interactive-object, trigger, teleport, taxi, and animation metadata. Lua and
dialogue-driven shared multiplayer behavior is not yet supported.
## Tests
CTest covers:
- repeated `LocationGameState::setup()` preserving mutable runtime state;
- ticking a prepared location with zero players;
- deterministic fixed-100 ms replay behavior;
- protocol-v2 parsing, validation, target normalization, state hashing, and
two-tick command ordering;
- a WebSocket initial-state/slot/destination/tick integration test;
- idle server startup;
- headless loading of `battle_zone_multiplayer/uni_exterior`.
The lockstep implementation currently targets identical executable and content
builds. Diagnostic state hashes detect divergence, but there is no automatic
checkpoint resynchronization or reconnect input history yet.
+203
View File
@@ -0,0 +1,203 @@
#include "game_server/InternalGameServer.h"
#include "game_server/NetworkProtocol.h"
#if defined(NETWORK)
#include "game_server/WebsocketGameServer.h"
#endif
#include "Campaign.h"
#include "LocationState.h"
#include "utils/Utils.h"
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <exception>
#include <iostream>
#include <limits>
#include <memory>
#include <string>
#include <thread>
#include <vector>
namespace {
std::atomic_bool stopRequested{false};
void requestStop(int)
{
stopRequested.store(true);
}
std::int64_t parsePositiveNumber(const char* value, const char* option)
{
try {
const std::string text(value);
std::size_t consumed = 0;
const long long parsed = std::stoll(text, &consumed);
if (consumed != text.size() || parsed <= 0)
throw std::invalid_argument("not positive");
return static_cast<std::int64_t>(parsed);
} catch (const std::exception&) {
throw std::runtime_error(
std::string("Invalid value for ") + option + ": " + value);
}
}
} // namespace
int main(int argc, char** argv)
{
std::int64_t tickMs = FRG::kSimulationTickMs;
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
bool sleepBetweenTicks = true;
std::string campaignId;
std::string locationId;
std::string campaignsPath = "resources/config/campaigns.json";
std::string listenAddress = "0.0.0.0";
std::int64_t listenPort = 8080;
try {
for (int index = 1; index < argc; ++index) {
const std::string option(argv[index]);
if (option == "--ticks" && index + 1 < argc) {
maxTicks = parsePositiveNumber(argv[++index], "--ticks");
} else if (option == "--tick-ms" && index + 1 < argc) {
tickMs = parsePositiveNumber(argv[++index], "--tick-ms");
} else if (option == "--no-sleep") {
sleepBetweenTicks = false;
} else if (option == "--campaign" && index + 1 < argc) {
campaignId = argv[++index];
} else if (option == "--location" && index + 1 < argc) {
locationId = argv[++index];
} else if (option == "--campaigns" && index + 1 < argc) {
campaignsPath = argv[++index];
} else if (option == "--address" && index + 1 < argc) {
listenAddress = argv[++index];
} else if (option == "--port" && index + 1 < argc) {
listenPort = parsePositiveNumber(argv[++index], "--port");
if (listenPort > 65535)
throw std::runtime_error("Invalid value for --port");
} else if (option == "--help") {
std::cout
<< "Usage: GameServer [--ticks N] [--tick-ms N] "
"[--no-sleep] [--campaign ID] [--location ID] "
"[--campaigns PATH] [--address IP] [--port N]\n";
return EXIT_SUCCESS;
} else {
throw std::runtime_error("Unknown or incomplete option: " + option);
}
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return EXIT_FAILURE;
}
if (tickMs != FRG::kSimulationTickMs) {
std::cerr << "Network lockstep requires --tick-ms "
<< FRG::kSimulationTickMs << '\n';
return EXIT_FAILURE;
}
std::signal(SIGINT, requestStop);
std::signal(SIGTERM, requestStop);
FRG::InternalGameServer server;
std::vector<std::string> playerSlots;
std::string compatibilityFingerprint = "lockstep-v2";
if (!campaignId.empty()) {
try {
FRG::CampaignRegistry::instance().loadFromJson(campaignsPath);
std::cout << "[server] Loading...\n" << std::flush;
const FRG::CampaignConfig* campaign =
FRG::CampaignRegistry::instance().find(campaignId);
if (!campaign)
throw std::runtime_error(
"Unknown campaign '" + campaignId + "'");
const std::string selectedLocation = locationId.empty()
? campaign->startLocation : locationId;
const FRG::LocationConfig* location =
campaign->findLocation(selectedLocation);
if (!location)
throw std::runtime_error(
"Campaign '" + campaignId +
"' has no location '" + selectedLocation + "'");
server.startCampaign(*campaign, std::string(), true, false);
server.prepareLocation(*location);
const std::string startState =
FRG::readTextFile(campaign->startStatePath);
if (!startState.empty()) {
try {
nlohmann::json persisted =
nlohmann::json::parse(startState);
nlohmann::json networkState;
FRG::ClientLocationState ignoredLocalState;
FRG::loadSinglePlayerPersistence(
persisted, networkState, ignoredLocalState);
networkState["campaignId"] = campaignId;
networkState["currentLocationId"] = selectedLocation;
server.load(networkState);
std::cout << "Loaded initial state '"
<< campaign->startStatePath << "'\n";
} catch (const std::exception& error) {
std::cerr << "Ignoring invalid initial state '"
<< campaign->startStatePath << "': "
<< error.what() << '\n';
}
}
server.activateLocation(selectedLocation);
playerSlots = campaign->networkPlayerSlots;
for (const std::string& slot : playerSlots) {
if (server.snapshot()->players.count(slot) == 0)
throw std::runtime_error(
"Multiplayer slot '" + slot +
"' is missing from the initial state");
}
compatibilityFingerprint =
campaign->networkCompatibility.empty()
? "lockstep-v2:" + campaignId
: campaign->networkCompatibility;
if (!campaign->tutorial) {
server.submit(FRG::SetTutorialStepCommand{
FRG::TutorialStep::Completed});
}
std::cout << "Loaded campaign '" << campaignId
<< "', location '" << selectedLocation
<< "' with shared player '" << server.localPlayerId() << "'\n";
} catch (const std::exception& error) {
std::cerr << "Server content setup failed: "
<< error.what() << '\n';
return EXIT_FAILURE;
}
}
const auto tickDuration = std::chrono::milliseconds(tickMs);
auto nextTick = std::chrono::steady_clock::now();
#if defined(NETWORK)
std::unique_ptr<FRG::WebsocketGameServer> websocketServer;
try {
websocketServer = std::make_unique<FRG::WebsocketGameServer>(
server, playerSlots, compatibilityFingerprint,
listenAddress, static_cast<std::uint16_t>(listenPort));
} catch (const std::exception& error) {
std::cerr << "WebSocket listener setup failed: "
<< error.what() << '\n';
return EXIT_FAILURE;
}
std::cout << "GameServer listening on ws://" << listenAddress << ':'
<< websocketServer->port() << " (" << tickMs << " ms tick)\n";
#else
std::cout << "GameServer started (network disabled, "
<< tickMs << " ms tick)\n";
#endif
for (std::int64_t tick = 0;
tick < maxTicks && !stopRequested.load(); ++tick) {
nextTick += tickDuration;
#if defined(NETWORK)
websocketServer->update();
#else
server.update(tickMs);
#endif
if (sleepBetweenTicks) std::this_thread::sleep_until(nextTick);
}
std::cout << "GameServer stopped\n";
return EXIT_SUCCESS;
}
+191
View File
@@ -0,0 +1,191 @@
#include "LocationGameState.h"
#include "game_server/InternalGameServer.h"
#include <cstdlib>
#include <filesystem>
#include <iostream>
namespace {
int fail(const char* message)
{
std::cout << "LocationGameStateTests: " << message << std::endl;
return EXIT_FAILURE;
}
} // namespace
int main()
{
FRG::ClientLocationState localState;
auto camera = localState.getOrCreate("test_location");
camera->cameraAzimuth = 0.75f;
camera->cameraInclination = 1.25f;
nlohmann::json localJson;
localState.save(localJson);
FRG::ClientLocationState restoredLocalState;
restoredLocalState.load(localJson);
const auto restoredCamera =
restoredLocalState.getOrCreate("test_location");
if (restoredCamera->cameraAzimuth != 0.75f ||
restoredCamera->cameraInclination != 1.25f) {
return fail("Local camera state did not round trip");
}
FRG::LocationNetworkState networkLocationState;
nlohmann::json networkLocationJson;
networkLocationState.save(networkLocationJson);
if (networkLocationJson.contains("cameraAzimuth") ||
networkLocationJson.contains("cameraInclination")) {
return fail("Network location state contains local camera fields");
}
const nlohmann::json authoritativeJson{
{"schemaVersion", FRG::GameState::kSchemaVersion},
{"campaignId", "test"}
};
nlohmann::json persistenceJson;
FRG::saveSinglePlayerPersistence(
authoritativeJson, localState, persistenceJson);
nlohmann::json restoredAuthoritativeJson;
FRG::ClientLocationState restoredPersistenceLocalState;
FRG::loadSinglePlayerPersistence(
persistenceJson, restoredAuthoritativeJson,
restoredPersistenceLocalState);
if (restoredAuthoritativeJson != authoritativeJson ||
restoredPersistenceLocalState.getOrCreate("test_location")
->cameraAzimuth != 0.75f) {
return fail("Composed single-player state did not round trip");
}
if (persistenceJson.at("networkState").contains("localState")) {
return fail("Local state leaked into authoritative state");
}
const std::filesystem::path repositoryRoot =
std::filesystem::path(__FILE__).parent_path().parent_path().parent_path();
std::filesystem::current_path(repositoryRoot);
FRG::CampaignRegistry::instance().loadFromJson(
"resources/config/campaigns.json");
const FRG::CampaignConfig* singlePlayerBattleZone =
FRG::CampaignRegistry::instance().find("battle_zone");
const FRG::CampaignConfig* multiplayerBattleZoneConfig =
FRG::CampaignRegistry::instance().find("battle_zone_multiplayer");
if (FRG::CampaignRegistry::instance().all().size() != 3 ||
!singlePlayerBattleZone || !multiplayerBattleZoneConfig) {
return fail("Expected three campaigns including both Battle Zone modes");
}
if (singlePlayerBattleZone->startStatePath !=
multiplayerBattleZoneConfig->startStatePath) {
return fail("Battle Zone modes do not share their initial state");
}
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" ||
multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
"player2" ||
multiplayerBattleZoneConfig->networkCompatibility.empty()) {
return fail("Multiplayer player slots were not loaded");
}
FRG::LocationDefinition definition;
definition.config.id = "test_location";
FRG::NpcData npc;
npc.id = "npc";
npc.name = "Test NPC";
npc.hp = 75.f;
definition.npcs.push_back(npc);
FRG::InteractiveObjectData object;
object.id = "object";
object.base.name = "Test Object";
object.base.meshPath = "unused.mesh";
object.isActive = true;
definition.interactiveObjects.push_back(object);
FRG::TriggerZoneDefinition trigger;
trigger.id = "trigger";
trigger.enabled = true;
definition.triggerZones.push_back(trigger);
FRG::TeleportDefinition teleport;
teleport.id = "teleport";
teleport.active = false;
definition.teleports.push_back(teleport);
FRG::LocationGameState state;
if (!state.setup(definition, {}))
return fail("First setup must initialize runtime fields");
if (state.npcs.size() != 1 || state.interactiveObjects.size() != 1)
return fail("Definitions were not applied");
state.npcs.at("npc")->hp = 12.f;
state.interactiveObjects.at("object")->isActive = false;
state.triggerZones.at("trigger").enabled = false;
state.teleportZones.at("teleport") = true;
state.hasPersistentState = true;
if (state.setup(definition, {}))
return fail("Persistent setup must not reinitialize runtime fields");
if (state.npcs.at("npc")->hp != 12.f ||
state.interactiveObjects.at("object")->isActive ||
state.triggerZones.at("trigger").enabled ||
!state.teleportZones.at("teleport")) {
return fail("Repeated setup did not preserve runtime state");
}
FRG::PlayerStateMap noPlayers;
state.update(50, noPlayers, false);
FRG::CampaignConfig battleZone;
battleZone.id = "battle_zone";
battleZone.questsPath = "resources/config/quests_battle_zone.json";
FRG::InternalGameServer server;
server.startCampaign(battleZone, {}, false, true);
const auto snapshot = server.snapshot();
const auto bot = snapshot->players.find("player2");
if (bot == snapshot->players.end())
return fail("Battle Zone test bot was not created");
const auto stand = static_cast<std::size_t>(FRG::AnimationState::STAND);
const auto walk = static_cast<std::size_t>(FRG::AnimationState::WALK);
if (bot->second.state.animationFrameCounts[stand] <= 1 ||
bot->second.state.animationFrameCounts[walk] <= 1) {
return fail("Battle Zone test bot animation metadata was not loaded");
}
FRG::InternalGameServer networkServer;
FRG::CampaignConfig multiplayerBattleZone = battleZone;
multiplayerBattleZone.id = "battle_zone_multiplayer";
multiplayerBattleZone.networkPlayerSlots = {
"player", "player2"};
networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
const auto networkSnapshot = networkServer.snapshot();
if (networkSnapshot->campaignId != "battle_zone_multiplayer")
return fail("Network campaign ID was not retained");
if (networkSnapshot->players.count("player") != 1)
return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot");
nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState);
loadedNetworkState["players"]["player2"] =
loadedNetworkState["players"]["player"];
loadedNetworkState["players"]["player2"]["position"] =
{4.f, 0.f, 5.f};
networkServer.load(loadedNetworkState);
const auto loadedNetworkSnapshot = networkServer.snapshot();
const auto loadedNetworkBot =
loadedNetworkSnapshot->players.find("player2");
if (loadedNetworkBot == loadedNetworkSnapshot->players.end() ||
loadedNetworkBot->second.state.animationFrameCounts[stand] <= 1 ||
loadedNetworkBot->second.state.animationFrameCounts[walk] <= 1) {
return fail(
"Loaded multiplayer slot did not restore animation metadata");
}
if (!loadedNetworkBot->second.state.position.isApprox(
Eigen::Vector3f(4.f, 0.f, 5.f))) {
return fail("Loaded multiplayer slot position was overwritten");
}
return EXIT_SUCCESS;
}
+128
View File
@@ -0,0 +1,128 @@
#include "game_server/NetworkProtocol.h"
#include "LocationState.h"
#include <cstdlib>
#include <iostream>
#include <string>
namespace {
void require(bool condition, const char* message)
{
if (condition) return;
std::cerr << "NetworkProtocolTests: " << message << '\n';
std::exit(EXIT_FAILURE);
}
} // namespace
int main()
{
FRG::LocationNetworkState locationState;
nlohmann::json savedLocationState;
locationState.save(savedLocationState);
const nlohmann::json original{
{"schemaVersion", 2},
{"campaignId", "battle_zone_multiplayer"},
{"players", {{"player", {{"hp", 200.0}}}}},
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
};
FRG::InitialStateMessage initial;
initial.state = original;
initial.assignedPlayerId = "player";
initial.snapshotTick = 42;
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
const std::string message = FRG::makeInitialStateMessage(initial);
FRG::InitialStateMessage parsed;
std::string error;
require(
FRG::parseInitialStateMessage(message, parsed, error),
"valid initial-state envelope was rejected");
require(parsed.state == original, "state payload did not round trip");
require(parsed.snapshotTick == 42, "snapshot tick did not round trip");
require(
parsed.assignedPlayerId == "player",
"assigned player did not round trip");
require(
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
"local camera state leaked into the network payload");
FRG::DestinationInputMessage input;
input.clientSequence = 7;
input.target = Eigen::Vector3f(1.234f, 0.f, -5.678f);
FRG::DestinationInputMessage parsedInput;
require(
FRG::parseDestinationInputMessage(
FRG::makeDestinationInputMessage(input), parsedInput, error),
"destination input did not round trip");
require(
parsedInput.clientSequence == 7 &&
parsedInput.target.isApprox(Eigen::Vector3f(1.23f, 0.f, -5.68f)),
"destination input was not normalized");
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back({1, "player", parsedInput.target});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
FRG::parseTickMessage(
FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip");
require(
parsedTick.tickId == 43 && parsedTick.commands.size() == 1,
"tick contents did not round trip");
require(
!FRG::parseInitialStateMessage(
R"({"protocolVersion":1,"type":"initial_state","payload":{}})",
parsed, error),
"unknown protocol version was accepted");
require(
!FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"command","payload":{}})",
parsedInput, error),
"unknown message type was accepted");
require(
!FRG::parseInitialStateMessage("not json", parsed, error),
"malformed JSON was accepted");
require(
!FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"destination_input","payload":{"clientSequence":1,"target":[1e999,0,0]}})",
parsedInput, error),
"non-finite destination was accepted");
std::string errorCode;
std::string errorDetail;
require(
FRG::parseErrorMessage(
FRG::makeErrorMessage("server_full", "No slot"),
errorCode, errorDetail) &&
errorCode == "server_full" && errorDetail == "No slot",
"error message did not round trip");
nlohmann::json hashState = original;
hashState["savedAt"] = "first";
const std::string firstHash = FRG::canonicalStateHash(hashState);
hashState["savedAt"] = "second";
require(
firstHash == FRG::canonicalStateHash(hashState),
"persistence timestamp changed deterministic hash");
FRG::LockstepTimeline timeline(2);
const std::uint64_t firstSequence =
timeline.schedule("player", Eigen::Vector3f(1.f, 0.f, 1.f));
const std::uint64_t secondSequence =
timeline.schedule("player_2", Eigen::Vector3f(2.f, 0.f, 2.f));
const FRG::TickMessage emptyTick = timeline.advance();
require(
emptyTick.tickId == 1 && emptyTick.commands.empty(),
"input-delay tick was not empty");
const FRG::TickMessage commandTick = timeline.advance();
require(
commandTick.tickId == 2 && commandTick.commands.size() == 2 &&
commandTick.commands[0].commandSequence == firstSequence &&
commandTick.commands[1].commandSequence == secondSequence,
"scheduled commands were not finalized in stable order");
return EXIT_SUCCESS;
}
+90
View File
@@ -0,0 +1,90 @@
#include "game_server/InternalGameServer.h"
#include "game_server/WebsocketGameClient.h"
#include "game_server/WebsocketGameServer.h"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <thread>
namespace {
void require(bool condition, const char* message)
{
if (condition) return;
std::cerr << "WebsocketLockstepTests: " << message << '\n';
std::exit(EXIT_FAILURE);
}
template <class Predicate>
bool waitFor(Predicate predicate)
{
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false;
}
} // namespace
int main()
{
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false);
FRG::WebsocketGameServer server(
authoritative, {"player"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() { return client->connected(); }),
"client did not connect");
require(
waitFor([&]() {
client->update(0);
return client->initialized();
}),
"client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned");
FRG::WebsocketGameClient overflow(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
overflow.update(0);
return overflow.rejected();
}),
"connection beyond configured slots was not rejected");
client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
return client->snapshot()->revision > 1;
}),
"client did not consume finalized lockstep ticks");
client.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
FRG::WebsocketGameClient replacement(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
replacement.update(0);
return replacement.initialized();
}),
"disconnected player slot was not reusable");
return EXIT_SUCCESS;
}
+4
View File
@@ -109,6 +109,10 @@ namespace FRG {
c.startStatePath = entry.value("startState", "");
c.questsPath = entry.value("quests", "");
c.tutorial = entry.value("tutorial", false);
c.networkPlayerSlots =
entry.value("networkPlayerSlots", std::vector<std::string>{});
c.networkCompatibility =
entry.value("networkCompatibility", std::string());
c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen));
for (const std::string& path : c.locationConfigPaths) {
LocationConfig loc;
+4
View File
@@ -55,6 +55,10 @@ namespace FRG {
std::string startStatePath;
std::string questsPath;
bool tutorial = false;
// Existing character IDs assigned to multiplayer connections in order.
std::vector<std::string> networkPlayerSlots;
// Version token shared by clients with identical deterministic content.
std::string networkCompatibility;
// UI layout shown while the campaign loads (preloaded with the rest of the UI).
std::string loadingScreenPath = kDefaultLoadingScreen;
+190 -431
View File
@@ -1,4 +1,4 @@
#include "Character.h"
#include "Character.h"
#include "render/TextRenderer.h"
#include <cmath>
#include <iostream>
@@ -9,15 +9,54 @@
#include "render/TextureManager.h"
#include "utils/Utils.h"
#include "TextModel.h"
#include "game_server/IGameServer.h"
#include "navigation/PathFinder.h"
#include "Location.h"
namespace FRG {
const float ATTACK_COOLDOWN_TIME = 1.6f;
extern float x;
extern float y;
Dialogue::TranslationDatabase Character::nameTranslations;
std::shared_ptr<const CharacterState> Character::stateSnapshot() const {
auto snapshot = gameServer_.snapshot();
if (!snapshot) return {};
auto player = snapshot->players.find(entityId_);
if (player != snapshot->players.end()) {
return std::shared_ptr<const CharacterState>(
std::move(snapshot), &player->second.state);
}
auto location = snapshot->locations.find(locationId_);
if (location == snapshot->locations.end()) return {};
auto npc = location->second.npcs.find(entityId_);
if (npc == location->second.npcs.end()) return {};
return std::shared_ptr<const CharacterState>(
std::move(snapshot), &npc->second.state);
}
bool Character::isMoving() const {
auto state = stateSnapshot();
return state && state->isMoving();
}
Eigen::Vector3f Character::getRequestedWalkTarget() const {
auto state = stateSnapshot();
return state ? state->getRequestedWalkTarget() : Eigen::Vector3f::Zero();
}
Eigen::Vector3f Character::getCurrentNavigationTarget() const {
auto state = stateSnapshot();
return state ? state->getCurrentNavigationTarget() : Eigen::Vector3f::Zero();
}
float Character::getHp() const {
auto state = stateSnapshot();
return state ? state->getHp() : 0.f;
}
void Character::loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile) {
auto& data = animations[animState];
data.model.LoadFromFile(filename, zipFile);
@@ -38,9 +77,10 @@ void Character::loadBinaryAnimation(AnimationState animState, const std::string&
}
}
std::unique_ptr<Character> Character::createFromState(CharacterState st, Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>();
c->state = std::move(st);
std::unique_ptr<Character> Character::createFromState(const CharacterState& st, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>(server, locationId, entityId);
const std::string zip = zipFile ? zipFile : "";
@@ -56,7 +96,7 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
for (const auto& [animState, path] : c->state.creationInfo.animationPaths)
for (const auto& [animState, path] : st.creationInfo.animationPaths)
loadAnim(animState, path);
auto loadTex = [&](const std::string& path) -> std::shared_ptr<Texture> {
@@ -70,27 +110,27 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
if (!c->state.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(c->state.creationInfo.singleTexturePath);
if (!st.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(st.creationInfo.singleTexturePath);
if (tex) c->setTexture(tex);
}
for (const auto& [meshName, texPath] : c->state.creationInfo.meshTexturePaths) {
for (const auto& [meshName, texPath] : st.creationInfo.meshTexturePaths) {
auto tex = loadTex(texPath);
if (tex) c->setTexture(meshName, tex);
}
if (!c->state.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = c->state.creationInfo.weaponMeshPath;
if (!st.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = st.creationInfo.weaponMeshPath;
try {
if (wp.size() >= 4 && wp.substr(wp.size() - 4) == ".bin")
c->weaponMesh.data = LoadModelFromBinFile(wp, zip);
else
c->weaponMesh.data = LoadFromTextFile02(wp, zip);
if (c->state.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(c->state.creationInfo.weaponMeshMove);
if (c->state.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(c->state.creationInfo.weaponMeshScale);
if (st.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(st.creationInfo.weaponMeshMove);
if (st.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(st.creationInfo.weaponMeshScale);
c->weaponMesh.RefreshVBO();
} catch (const std::exception& e) {
@@ -98,100 +138,14 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
}
c->weaponTexture = loadTex(c->state.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = c->state.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = c->state.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = c->state.creationInfo.weaponInitialRotation.toRotationMatrix();
c->weaponTexture = loadTex(st.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = st.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = st.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = st.creationInfo.weaponInitialRotation.toRotationMatrix();
c->setTarget(c->state.position);
return c;
}
void Character::setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived, std::string callbackName) {
Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
(normalizedTarget - state.requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool alreadyMovingToTarget =
!state.pathWaypoints.empty() || (state.walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool stoppedAfterFailedPath =
state.pathWaypoints.empty() && (state.walkTarget - state.position).norm() <= TARGET_REPLAN_THRESHOLD;
if (!onArrived && sameRequestedTarget && (alreadyMovingToTarget || stoppedAfterFailedPath)) {
return;
}
state.requestedWalkTarget = normalizedTarget;
state.onArrivedCallback = std::move(onArrived);
state.onArrivedCallbackName = std::move(callbackName);
if (pathPlanner) {
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
return;
}
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
state.walkTarget = normalizedTarget;
}
void Character::stopAndRotateToTarget(int npcId)
{
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName = "";
state.walkTarget = state.position;
state.requestedWalkTarget = state.position;
state.faceTargetIndex = npcId;
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
void Character::setPathPlanner(PathPlanner planner) {
pathPlanner = std::move(planner);
}
void Character::resetPlayerAfterDeath() {
if (!state.isPlayer) return;
state.currentState = AnimationState::STAND;
state.resetAnim = true;
state.showWeapon = false;
state.battle_state = 0;
state.attack = 0;
}
void Character::forceReplan() {
if (!pathPlanner) return;
const Eigen::Vector3f normalizedTarget(state.requestedWalkTarget.x(), 0.f, state.requestedWalkTarget.z());
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
}
void Character::setTexture(std::shared_ptr<FRG::Texture> texture) {
for (auto& animEntry : animations) {
for (const auto& name : animEntry.second.model.meshNamesOrdered) {
@@ -204,270 +158,37 @@ void Character::setTexture(const std::string& meshName, std::shared_ptr<Texture>
meshTextures[meshName] = std::move(tex);
}
AnimationState Character::resolveActiveState() const {
AnimationState Character::resolveActiveState(const CharacterState& state) const {
if (animations.count(state.currentState)) return state.currentState;
return AnimationState::STAND;
}
float Character::getSpeedMultiplier(float transitionProgress) const {
if (state.isPlayer) {
return 1.0f + (1.5f - 1.0f) * transitionProgress;
} else {
return 1.0f - (1.0f - 0.1f) * transitionProgress;
}
}
void Character::update(int64_t deltaMs, const CharacterResolver& resolver) {
if (!state.enabled) return;
if (state.slowMoTimeRemaining > 0.0f) {
state.slowMoTimeRemaining -= static_cast<float>(deltaMs) / 1000.0f;
float transitionProgress;
if (state.slowMoTimeRemaining > state.slowMoActiveTime - state.slowMoTransitionTime) {
float timeInTransition = state.slowMoActiveTime - state.slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / state.slowMoTransitionTime);
} else if (state.slowMoTimeRemaining < state.slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, state.slowMoTimeRemaining / state.slowMoTransitionTime);
} else {
transitionProgress = 1.0f;
}
float speedMultiplier = getSpeedMultiplier(transitionProgress);
deltaMs = static_cast<int64_t>(static_cast<float>(deltaMs) * speedMultiplier);
}
Character* attackTarget = resolver ? resolver(state.attackTargetIndex) : nullptr;
Character* faceTarget = resolver ? resolver(state.faceTargetIndex) : nullptr;
Eigen::Vector3f activeTarget;
Eigen::Vector3f lookTarget;
if (attackTarget == nullptr) {
state.battle_state = 0;
state.attack = 0;
}
if (attackTarget != nullptr) {
float distToGhost = (attackTarget->state.position - state.position).norm();
if (distToGhost >= state.visibleRange) {
if (state.isPlayer) {
setTarget(attackTarget->state.position);
}
state.battle_state = 0;
} else if (distToGhost < state.visibleRange && distToGhost >= kAttackRange) {
setTarget(attackTarget->state.position);
state.battle_state = 0;
} else {
state.battle_state = 1;
}
}
if (!state.pathWaypoints.empty() && state.currentWaypointIndex < state.pathWaypoints.size()) {
activeTarget = state.pathWaypoints[state.currentWaypointIndex];
} else {
activeTarget = state.walkTarget;
}
if (state.battle_state == 0) {
lookTarget = activeTarget;
} else {
lookTarget = attackTarget->state.position;
}
if (state.currentState == AnimationState::WALK || state.currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - state.position;
toTarget.y() = 0.f;
float dist = toTarget.norm();
if (dist > CharacterState::kWalkThreshold) {
Eigen::Vector3f dir = toTarget / dist;
float moveAmount = state.walkSpeed * static_cast<float>(deltaMs) / 1000.f;
if (moveAmount >= dist) {
state.position = activeTarget;
state.position.y() = 0.f;
} else {
state.position += dir * moveAmount;
}
state.targetFacingAngle = atan2(dir.x(), -dir.z());
state.currentState = AnimationState::WALK;
} else {
state.currentState = AnimationState::STAND;
const bool hasNextWaypoint = !state.pathWaypoints.empty() &&
state.currentWaypointIndex + 1 < state.pathWaypoints.size();
if (hasNextWaypoint) {
++state.currentWaypointIndex;
} else {
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
if (!hasNextWaypoint && state.onArrivedCallback) {
auto cb = std::move(state.onArrivedCallback);
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
cb();
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->state.position - state.position;
toFace.y() = 0.f;
float d = toFace.norm();
if (d > 1e-3f) {
state.targetFacingAngle = atan2(toFace.x() / d, -toFace.z() / d);
} else {
state.targetFacingAngle = state.facingAngle;
}
}
}
}
if (state.hp <= 0) {
if (state.currentState != AnimationState::ACTION_TO_DEATH &&
state.currentState != AnimationState::DEATH_IDLE) {
state.currentState = AnimationState::ACTION_TO_DEATH;
state.resetAnim = true;
state.attackTargetIndex = CharacterState::kNoTarget;
}
} else {
if (state.battle_state == 1) {
state.targetFacingAngle = atan2(lookTarget.x() - state.position.x(),
-(lookTarget.z() - state.position.z()));
if (state.currentState == AnimationState::STAND || state.currentState == AnimationState::WALK) {
state.currentState = AnimationState::STAND_TO_ACTION;
state.resetAnim = true;
}
if (state.canAttack && state.attack == 0 && state.attack_cooldown < 0.f &&
state.currentState == AnimationState::ACTION_IDLE) {
state.attack = 1;
state.attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (state.canAttack && state.attack_cooldown >= 0.f) {
state.attack_cooldown -= deltaMs / 1000.f;
}
if (state.attack == 1 && state.currentState == AnimationState::ACTION_IDLE) {
if (attackTarget != nullptr && attackTarget->state.hp > 0) {
state.currentState = AnimationState::ACTION_ATTACK;
state.resetAnim = true;
}
}
} else {
if (state.currentState == AnimationState::STAND_TO_ACTION ||
state.currentState == AnimationState::ACTION_IDLE ||
state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_TO_STAND;
state.resetAnim = true;
}
}
}
// Rotate toward target facing angle at constant angular speed
float angleDiff = state.targetFacingAngle - state.facingAngle;
while (angleDiff > static_cast<float>(M_PI)) angleDiff -= 2.f * static_cast<float>(M_PI);
while (angleDiff < -static_cast<float>(M_PI)) angleDiff += 2.f * static_cast<float>(M_PI);
float rotStep = state.rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotStep) {
state.facingAngle = state.targetFacingAngle;
// One-shot: once aligned, clear the face target so the NPC stops tracking.
state.faceTargetIndex = CharacterState::kNoTarget;
} else {
state.facingAngle += (angleDiff > 0.f ? rotStep : -rotStep);
}
if (!state.isPlayer && state.hp > 0.f && state.battle_state == 0 && state.maxHomeDrift > 0.f) {
state.homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (state.homeDriftCheckTimer <= 0.f) {
state.homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = state.homePosition - state.position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > state.maxHomeDrift) {
setTarget(state.homePosition);
}
}
}
void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
if (hitSparkEmitter.isConfigured()) {
hitSparkEmitter.update(static_cast<float>(deltaMs));
}
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (events) {
if (events->deathAnimationComplete && onDeathAnimComplete)
onDeathAnimComplete();
if (events->attackImpact && audioPlayer) {
audioPlayer->playSoundAsync(state.isPlayer
? "resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg"
: "resources/audio/13925__adcbicycle__21.ogg");
}
}
if (!state.enabled) return;
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (state.resetAnim) {
state.resetAnim = false;
anim.currentFrame = 0;
}
int prevFrame = anim.currentFrame;
anim.currentFrame += static_cast<float>(deltaMs) / 24.f;
if (static_cast<int>(anim.currentFrame) >= 20 && state.currentState == AnimationState::STAND_TO_ACTION) {
state.showWeapon = true;
} else if (static_cast<int>(anim.currentFrame) <= 32 && state.currentState == AnimationState::ACTION_TO_STAND) {
state.showWeapon = true;
} else if (state.currentState == AnimationState::ACTION_ATTACK_2 ||
state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_IDLE) {
state.showWeapon = true;
} else {
state.showWeapon = false;
}
auto attackDirectionToTarget = [&]() {
Eigen::Vector3f dir = attackTarget->state.position - state.position;
dir.y() = 0.f;
float n = dir.norm();
if (n > 1e-3f) dir /= n;
else dir = Eigen::Vector3f::Zero();
return dir;
};
if (prevFrame < 19 && static_cast<int>(anim.currentFrame) >= 19 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
if (attackTarget != nullptr) {
attackTarget->applyDamage(10.f, attackDirectionToTarget(), index);
if (audioPlayer) {
if (state.isPlayer)
{
audioPlayer->playSoundAsync("resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg");
}
else
{
audioPlayer->playSoundAsync("resources/audio/13925__adcbicycle__21.ogg");
}
}
}
}
int frms = anim.model.animations[0].keyFrames[anim.model.animations[0].keyFrames.size() - 1].frame;
if (static_cast<int>(anim.currentFrame) >= frms) {
anim.currentFrame = anim.model.startingFrame;
if (state.currentState == AnimationState::STAND_TO_ACTION) {
state.currentState = AnimationState::ACTION_IDLE;
}
if (state.currentState == AnimationState::ACTION_TO_STAND) {
state.currentState = AnimationState::STAND;
}
if (state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_IDLE;
state.attack = 0;
}
if (state.currentState == AnimationState::ACTION_TO_DEATH) {
state.currentState = AnimationState::DEATH_IDLE;
if (state.onDeathAnimComplete) {
state.onDeathAnimComplete();
}
}
}
anim.currentFrame = (std::min)(
state.animationFrame, static_cast<float>((std::max)(0, anim.totalFrames - 1)));
if (static_cast<int>(anim.currentFrame) != anim.lastFrame) {
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
@@ -488,14 +209,17 @@ void Character::draw(Renderer& renderer) {
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (!state.enabled) return;
if (state.useGpuSkinning) {
drawGpuSkinning(renderer);
drawGpuSkinning(renderer, state);
return;
}
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -528,14 +252,14 @@ void Character::draw(Renderer& renderer) {
renderer.PopMatrix();
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
}
void Character::drawGpuSkinning(Renderer& renderer) {
AnimationState drawState = resolveActiveState();
void Character::drawGpuSkinning(Renderer& renderer, const CharacterState& state) {
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -586,15 +310,15 @@ void Character::drawGpuSkinning(Renderer& renderer) {
renderer.shaderManager.PushShader("fog");
renderer.RenderUniform1i(textureUniformName, 0);
renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosSkin);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.shaderManager.PopShader();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
}
bool Character::prepareGpuSkinning() {
AnimationState drawState = resolveActiveState();
bool Character::prepareGpuSkinning(const CharacterState& state) {
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return false;
@@ -610,27 +334,11 @@ bool Character::prepareGpuSkinning() {
return true;
}
void Character::tryAttackCombo()
{
logger() << "Try Attack Combo" << std::endl;
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (static_cast<int>(anim.currentFrame) >= 20 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
logger() << "Try Attack Combo Success" << std::endl;
anim.currentFrame = anim.model.startingFrame;
}
}
void Character::drawAttachedWeapon(Renderer& renderer) {
void Character::drawAttachedWeapon(Renderer& renderer, const CharacterState& state) {
if (!state.showWeapon) return;
if (weaponMesh.data.PositionData.size() == 0 || !weaponTexture) return;
auto it = animations.find(resolveActiveState());
auto it = animations.find(resolveActiveState(state));
if (it == animations.end()) return;
auto& anim = it->second;
@@ -660,16 +368,19 @@ void Character::drawAttachedWeapon(Renderer& renderer) {
// ==================== Shadow depth pass ====================
void Character::drawShadowDepth(Renderer& renderer) {
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (!state.enabled) return;
if (state.useGpuSkinning) {
drawShadowDepthGpuSkinning(renderer);
drawShadowDepthGpuSkinning(renderer, state);
} else {
drawShadowDepthCpu(renderer);
drawShadowDepthCpu(renderer, state);
}
}
void Character::drawShadowDepthCpu(Renderer& renderer) {
AnimationState drawState = resolveActiveState();
void Character::drawShadowDepthCpu(Renderer& renderer, const CharacterState& state) {
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -691,17 +402,17 @@ void Character::drawShadowDepthCpu(Renderer& renderer) {
}
renderer.PopMatrix();
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
}
void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
void Character::drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterState& state) {
CheckGlError(__FILE__, __LINE__);
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
CheckGlError(__FILE__, __LINE__);
if (!prepareGpuSkinning()) return;
if (!prepareGpuSkinning(state)) return;
CheckGlError(__FILE__, __LINE__);
static const std::string shadowSkinningShader = "shadow_depth_skinning";
@@ -733,13 +444,16 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
renderer.PopMatrix();
renderer.shaderManager.PopShader();
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
CheckGlError(__FILE__, __LINE__);
}
// ==================== Main pass with shadows ====================
void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (!state.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
@@ -749,14 +463,14 @@ void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightF
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
drawGpuSkinningWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
drawGpuSkinningWithShadow(renderer, state, lightFromCamera, shadowMapTex, lightDirCamera);
} else {
drawCpuWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
drawCpuWithShadow(renderer, state, lightFromCamera, shadowMapTex, lightDirCamera);
}
}
void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
AnimationState drawState = resolveActiveState();
void Character::drawCpuWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -801,20 +515,20 @@ void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lig
renderer.shaderManager.PushShader("fog");
renderer.RenderUniform1i(textureUniformName, 0);
renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosShadow);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.shaderManager.PopShader();
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
}
void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
AnimationState drawState = resolveActiveState();
void Character::drawGpuSkinningWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
CheckGlError(__FILE__, __LINE__);
if (!prepareGpuSkinning()) return;
if (!prepareGpuSkinning(state)) return;
CheckGlError(__FILE__, __LINE__);
static const std::string skinningShadowShader = "fog_skinning_shadow";
@@ -867,7 +581,7 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri
renderer.shaderManager.PushShader("fog");
renderer.RenderUniform1i(textureUniformName, 0);
renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosSkinShadow);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.shaderManager.PopShader();
CheckGlError(__FILE__, __LINE__);
@@ -887,6 +601,9 @@ void Character::drawNight(Renderer& renderer,
const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor)
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (!state.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
@@ -896,24 +613,25 @@ void Character::drawNight(Renderer& renderer,
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
drawNightGpuSkinning(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
drawNightGpuSkinning(renderer, state, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor);
} else {
drawNightCpu(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
drawNightCpu(renderer, state, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor);
}
}
void Character::drawNightGpuSkinning(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor)
{
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
if (!prepareGpuSkinning()) return;
if (!prepareGpuSkinning(state)) return;
static const std::string nightSkinningShader = "night_fog_skinning";
static const std::string boneMatricesUniform = "uBoneMatrices[0]";
@@ -972,7 +690,7 @@ void Character::drawNightGpuSkinning(Renderer& renderer,
renderer.RenderUniform2fvArray("uPointLightWorldXZ[0]", plCount, plWorldXZ);
renderer.RenderUniform2fvArray("uPointLightLimitXZ[0]", plCount, plLimitXZ);
}
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.shaderManager.PopShader();
renderer.PopProjectionMatrix();
@@ -980,11 +698,12 @@ void Character::drawNightGpuSkinning(Renderer& renderer,
}
void Character::drawNightCpu(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor)
{
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -1030,7 +749,7 @@ void Character::drawNightCpu(Renderer& renderer,
renderer.PopMatrix();
renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosNight);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
@@ -1045,6 +764,9 @@ void Character::drawNightWithShadow(Renderer& renderer,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor)
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (!state.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
@@ -1053,27 +775,28 @@ void Character::drawNightWithShadow(Renderer& renderer,
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
drawNightGpuSkinningWithShadow(renderer, plPosEye, plDirEye, plColors, plCount,
drawNightGpuSkinningWithShadow(renderer, state, plPosEye, plDirEye, plColors, plCount,
plWorldXZ, plLimitXZ, cameraViewInverse,
lightFromCamera, shadowMapTex, ambientColor, fogColor);
} else {
drawNightCpuWithShadow(renderer, plPosEye, plDirEye, plColors, plCount,
drawNightCpuWithShadow(renderer, state, plPosEye, plDirEye, plColors, plCount,
plWorldXZ, plLimitXZ, cameraViewInverse,
lightFromCamera, shadowMapTex, ambientColor, fogColor);
}
}
void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ,
const Eigen::Matrix4f& cameraViewInverse,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor)
{
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
if (!prepareGpuSkinning()) return;
if (!prepareGpuSkinning(state)) return;
static const std::string nightShadowSkinningShader = "night_fog_skinning_shadow";
static const std::string boneMatricesUniform = "uBoneMatrices[0]";
@@ -1143,7 +866,7 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
}
renderer.RenderUniform2fvArray("uPointLightWorldXZ[0]", plCount, plWorldXZ);
renderer.RenderUniform2fvArray("uPointLightLimitXZ[0]", plCount, plLimitXZ);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.shaderManager.PopShader();
renderer.PopProjectionMatrix();
@@ -1151,13 +874,14 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
}
void Character::drawNightCpuWithShadow(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ,
const Eigen::Matrix4f& cameraViewInverse,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor)
{
AnimationState drawState = resolveActiveState();
AnimationState drawState = resolveActiveState(state);
auto it = animations.find(drawState);
if (it == animations.end()) return;
@@ -1211,7 +935,7 @@ void Character::drawNightCpuWithShadow(Renderer& renderer,
renderer.PopMatrix();
renderer.RenderUniform3fv("uPlayerEyePos", playerEyePosNightShadow);
drawAttachedWeapon(renderer);
drawAttachedWeapon(renderer, state);
renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader();
@@ -1232,14 +956,16 @@ void Character::setupHitSparks(std::shared_ptr<Texture> sparkTexture) {
hitSparkEmitter.markConfigured();
}
void Character::applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection, int attackerIndex) {
logger() << "Character::applyDamage" << std::endl;
state.hp -= damageAmount;
if (state.hp < 0) state.hp = 0;
if (state.onHpChanged) state.onHpChanged(state.hp, state.initialHp, attackerIndex);
void Character::presentDamage(float hp, float maxHp,
const Eigen::Vector3f& attackDirection,
const std::string& attackerId) {
if (onHpChanged)
onHpChanged(hp, maxHp, attackerId);
if (!hitSparkEmitter.isConfigured()) return;
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
Eigen::Vector3f emitPos = state.position + Eigen::Vector3f(0.f, 1.f, 0.f);
hitSparkEmitter.resetEmissionPoints({ emitPos });
@@ -1258,16 +984,18 @@ void Character::prepareHitSparksForDraw(const Eigen::Matrix4f& cameraViewMatrix)
hitSparkEmitter.prepareForDraw(cameraViewMatrix);
}
void Character::drawName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
static void drawCharacterNameLabel(
TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix,
const CharacterState& state,
const std::string& displayName,
const std::array<float, 4>& color)
{
if (state.isPlayer) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
if (state.npcName.empty()) return;
if (!state.enabled || state.hp <= 0.f || displayName.empty()) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f);
Eigen::Vector4f worldPos(
state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return;
@@ -1277,6 +1005,18 @@ void Character::drawName(TextRenderer& textRenderer,
float screenX = (ndcX * 0.5f + 0.5f) * Environment::projectionWidth;
float screenY = (ndcY * 0.5f + 0.5f) * Environment::projectionHeight;
textRenderer.drawText(displayName, screenX, screenY, 1.0f, true, color);
}
void Character::drawName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (state.isPlayer) return;
if (state.npcName.empty()) return;
std::array<float, 4> color = state.canAttack
? std::array<float, 4>{ 1.f, 0.f, 0.f, 1.f }
@@ -1284,13 +1024,32 @@ void Character::drawName(TextRenderer& textRenderer,
const std::string& translatedName = Character::nameTranslations.translateRef(state.npcName, g_currentLanguage);
textRenderer.drawText(translatedName, screenX, screenY, 1.0f, true, color);
drawCharacterNameLabel(
textRenderer, cameraViewMatrix, projectionMatrix,
state, translatedName, color);
}
void Character::drawPlayerName(
TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix,
const std::string& displayName)
{
auto snapshot = stateSnapshot();
if (!snapshot) return;
static const std::array<float, 4> color = {0.f, 1.f, 0.f, 1.f};
drawCharacterNameLabel(
textRenderer, cameraViewMatrix, projectionMatrix,
*snapshot, displayName, color);
}
void Character::drawHealthBar(Renderer& renderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
if (state.isPlayer || !state.canAttack) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
+50 -55
View File
@@ -11,30 +11,27 @@
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <utility>
#include "dialogue/TranslationDatabase.h"
#include "AudioPlayerAsync.h"
namespace FRG {
class TextRenderer;
class IGameServer;
class Character {
public:
Character(IGameServer& server, std::string locationId, std::string entityId)
: gameServer_(server), locationId_(std::move(locationId)),
entityId_(std::move(entityId)) {}
const std::string& entityId() const { return entityId_; }
void bindToLocation(std::string locationId) { locationId_ = std::move(locationId); }
// The aliasing shared_ptr keeps the owning GameSnapshot alive for as long
// as the returned CharacterState is being read.
std::shared_ptr<const CharacterState> stateSnapshot() const;
static constexpr float kAttackRange = 1.25f;
// Resolves a character index to a live Character pointer.
// kNoTarget (-1) → nullptr, kPlayerIndex (-2) → Location::player, 0..n → npcs[n].
using CharacterResolver = std::function<Character*(int index)>;
using PathPlanner = std::function<std::vector<Eigen::Vector3f>(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end)>;
// All serializable game state (position, hp, flags, pathfinding, etc.)
CharacterState state;
int index = CharacterState::kNoTarget;
// ---- Asset loading ----
void loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
void loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
@@ -46,31 +43,19 @@ public:
// Creates a fully-loaded Character from a CharacterState (including its creationInfo).
// Loads all animations, textures, and weapon mesh/texture from the paths stored in creationInfo.
static std::unique_ptr<Character> createFromState(CharacterState st, Renderer& renderer, const char* zipFile);
static std::unique_ptr<Character> createFromState(
const CharacterState& initialState, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile);
// ---- Per-frame update ----
// resolver maps character indices to live pointers so attackTargetIndex / faceTargetIndex
// can be dereferenced inside update(). Pass nullptr only if no targets are set.
void update(int64_t deltaMs, const CharacterResolver& resolver = nullptr);
void setPathPlanner(PathPlanner planner);
// ---- Navigation (path data lives in state; pathPlanner lives here) ----
// callbackName, if given, is the global Lua function name backing onArrived —
// stored in state.onArrivedCallbackName so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived = nullptr, std::string callbackName = "");
void stopAndRotateToTarget(int npcId);
void forceReplan();
void stopInPlace() { state.stopInPlace(); }
// ---- Reset ----
void resetPlayerAfterDeath();
void tryAttackCombo();
// Applies authoritative animation state to runtime resources and consumes
// presentation-only events emitted by CharacterState::update.
void update(int64_t deltaMs, const CharacterUpdateEvents* events = nullptr);
// ---- Damage ----
void applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection = Eigen::Vector3f::Zero(), int attackerIndex = CharacterState::kNoTarget);
void presentDamage(float hp, float maxHp, const Eigen::Vector3f& attackDirection,
const std::string& attackerId);
// ---- Spark setup ----
void setupHitSparks(std::shared_ptr<Texture> sparkTexture);
@@ -80,6 +65,10 @@ public:
void drawName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix);
void drawPlayerName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix,
const std::string& displayName);
void drawHealthBar(Renderer& renderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix);
@@ -101,14 +90,11 @@ public:
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor);
// ---- Query helpers (delegate to state) ----
bool isMoving() const { return state.isMoving(); }
const Eigen::Vector3f& getRequestedWalkTarget() const { return state.getRequestedWalkTarget(); }
Eigen::Vector3f getCurrentNavigationTarget() const { return state.getCurrentNavigationTarget(); }
float getHp() const { return state.getHp(); }
void setHp(float newHp) { state.setHp(newHp); }
float getSpeedMultiplier(float transitionProgress) const;
// ---- Query helpers (read immutable snapshots) ----
bool isMoving() const;
Eigen::Vector3f getRequestedWalkTarget() const;
Eigen::Vector3f getCurrentNavigationTarget() const;
float getHp() const;
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
VertexRenderStruct weaponMesh;
@@ -123,38 +109,47 @@ public:
static Dialogue::TranslationDatabase nameTranslations;
private:
friend class Game;
friend class Location;
friend class ScriptEngine;
friend class LocationEditor;
friend class PathVisualization;
IGameServer& gameServer_;
std::string locationId_;
std::string entityId_;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, const std::string&)> onHpChanged;
std::map<AnimationState, BoneAnimationDataNew> animations;
VertexRenderStruct modelMutable;
VertexRenderStruct healthBarMesh;
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
PathPlanner pathPlanner;
AnimationState resolveActiveState(const CharacterState& state) const;
bool prepareGpuSkinning(const CharacterState& state);
void drawAttachedWeapon(Renderer& renderer, const CharacterState& state);
static constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
static constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
AnimationState resolveActiveState() const;
bool prepareGpuSkinning();
void drawAttachedWeapon(Renderer& renderer);
void drawGpuSkinning(Renderer& renderer);
void drawShadowDepthGpuSkinning(Renderer& renderer);
void drawShadowDepthCpu(Renderer& renderer);
void drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
void drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
void drawGpuSkinning(Renderer& renderer, const CharacterState& state);
void drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterState& state);
void drawShadowDepthCpu(Renderer& renderer, const CharacterState& state);
void drawGpuSkinningWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
void drawCpuWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
void drawNightGpuSkinning(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor);
void drawNightCpu(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor);
void drawNightGpuSkinningWithShadow(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
void drawNightCpuWithShadow(Renderer& renderer,
const CharacterState& state,
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
+319 -6
View File
@@ -1,7 +1,28 @@
#include "CharacterState.h"
#include "LocationState.h"
#include <algorithm>
#include <cmath>
namespace FRG {
namespace {
constexpr float ATTACK_COOLDOWN_TIME = 1.6f;
constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
constexpr float PI = 3.14159265358979323846f;
}
void CharacterState::configureAsPlayer() {
walkSpeed = 3.0f;
rotationSpeed = 8.0f;
modelScale = 1.f;
modelCorrectionRotation = Eigen::Quaternionf(
Eigen::AngleAxisf(PI, Eigen::Vector3f::UnitY()) *
Eigen::AngleAxisf(-PI * 0.5f, Eigen::Vector3f::UnitX()));
canAttack = true;
isPlayer = true;
}
bool CharacterState::isMoving() const {
Eigen::Vector3f toTarget = walkTarget - position;
toTarget.y() = 0.f;
@@ -20,14 +41,304 @@ void CharacterState::stopInPlace() {
requestedWalkTarget = walkTarget;
pathWaypoints.clear();
currentWaypointIndex = 0;
onArrivedCallback = nullptr;
onArrivedCallbackName.clear();
}
void CharacterState::setTarget(
const Eigen::Vector3f& target,
std::string callbackName,
const LocationNetworkState& location,
const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
(normalizedTarget - requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool alreadyMovingToTarget =
!pathWaypoints.empty() ||
(walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool stoppedAfterFailedPath =
pathWaypoints.empty() &&
(walkTarget - position).norm() <= TARGET_REPLAN_THRESHOLD;
if (callbackName.empty() && sameRequestedTarget &&
(alreadyMovingToTarget || stoppedAfterFailedPath)) {
return;
}
requestedWalkTarget = normalizedTarget;
onArrivedCallbackName = std::move(callbackName);
pathWaypoints = location.planPathExceptMyself(position, normalizedTarget, entityId);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear();
}
void CharacterState::forceReplan(
const LocationNetworkState& location, const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
pathWaypoints = location.planPathExceptMyself(position, normalizedTarget, entityId);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear();
}
void CharacterState::stopAndRotateToTarget(std::string characterId)
{
stopInPlace();
faceTargetId = std::move(characterId);
}
void CharacterState::resetPlayerAfterDeath()
{
if (!isPlayer) return;
currentState = AnimationState::STAND;
resetAnim = true;
showWeapon = false;
battle_state = 0;
attack = 0;
}
void CharacterState::tryAttackCombo()
{
if (static_cast<int>(animationFrame) < 20 ||
(currentState != AnimationState::ACTION_ATTACK &&
currentState != AnimationState::ACTION_ATTACK_2)) {
return;
}
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
void CharacterState::setHp(float newHp) {
hp = newHp;
if (hp < 0.f) hp = 0.f;
if (onHpChanged) onHpChanged(hp, initialHp, CharacterState::kNoTarget);
}
void CharacterState::setAnimationMetadata(
AnimationState state, int startFrame, int frameCount)
{
const auto index = static_cast<std::size_t>(state);
if (index < animationFrameCounts.size()) {
animationStartFrames[index] = (std::max)(0, startFrame);
animationFrameCounts[index] = (std::max)(1, frameCount);
}
}
CharacterUpdateEvents CharacterState::update(
std::int64_t deltaMs, const CharacterUpdateContext& context)
{
CharacterUpdateEvents events;
if (!enabled) return events;
if (slowMoTimeRemaining > 0.0f) {
slowMoTimeRemaining = (std::max)(
0.0f, slowMoTimeRemaining - static_cast<float>(deltaMs) / 1000.0f);
float transitionProgress = 1.0f;
if (slowMoTimeRemaining > slowMoActiveTime - slowMoTransitionTime) {
const float timeInTransition = slowMoActiveTime - slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / slowMoTransitionTime);
} else if (slowMoTimeRemaining < slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, slowMoTimeRemaining / slowMoTransitionTime);
}
const float speedMultiplier = isPlayer
? 1.0f + 0.5f * transitionProgress
: 1.0f - 0.9f * transitionProgress;
deltaMs = static_cast<std::int64_t>(
static_cast<float>(deltaMs) * speedMultiplier);
}
const CharacterObservation* attackTarget = context.attackTarget;
const CharacterObservation* faceTarget = context.faceTarget;
if (!attackTarget) {
battle_state = 0;
attack = 0;
attackTargetId.clear();
} else {
const float distance = (attackTarget->position - position).norm();
if (distance >= visibleRange) {
if (isPlayer &&
(attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else if (distance >= 1.25f) {
if ((attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else {
battle_state = 1;
}
}
const Eigen::Vector3f activeTarget = getCurrentNavigationTarget();
const Eigen::Vector3f lookTarget =
battle_state == 1 && attackTarget ? attackTarget->position : activeTarget;
if (currentState == AnimationState::WALK || currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - position;
toTarget.y() = 0.f;
const float distance = toTarget.norm();
if (distance > kWalkThreshold) {
const Eigen::Vector3f direction = toTarget / distance;
const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f;
position = moveAmount >= distance ? activeTarget : position + direction * moveAmount;
position.y() = 0.f;
targetFacingAngle = std::atan2(direction.x(), -direction.z());
currentState = AnimationState::WALK;
} else {
currentState = AnimationState::STAND;
const bool hasNextWaypoint =
!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size();
if (hasNextWaypoint) {
++currentWaypointIndex;
} else {
pathWaypoints.clear();
currentWaypointIndex = 0;
if (!onArrivedCallbackName.empty()) {
events.arrivedCallbackName = onArrivedCallbackName;
onArrivedCallbackName.clear();
}
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->position - position;
toFace.y() = 0.f;
const float distanceToFace = toFace.norm();
targetFacingAngle = distanceToFace > 1e-3f
? std::atan2(toFace.x() / distanceToFace, -toFace.z() / distanceToFace)
: facingAngle;
} else if (!faceTargetId.empty()) {
faceTargetId.clear();
}
}
}
if (hp <= 0.f) {
if (currentState != AnimationState::ACTION_TO_DEATH &&
currentState != AnimationState::DEATH_IDLE) {
currentState = AnimationState::ACTION_TO_DEATH;
resetAnim = true;
attackTargetId.clear();
}
} else if (battle_state == 1 && attackTarget) {
targetFacingAngle = std::atan2(
lookTarget.x() - position.x(), -(lookTarget.z() - position.z()));
if (currentState == AnimationState::STAND || currentState == AnimationState::WALK) {
currentState = AnimationState::STAND_TO_ACTION;
resetAnim = true;
}
if (canAttack && attack == 0 && attack_cooldown < 0.f &&
currentState == AnimationState::ACTION_IDLE) {
attack = 1;
attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (canAttack && attack_cooldown >= 0.f)
attack_cooldown -= static_cast<float>(deltaMs) / 1000.f;
if (attack == 1 && currentState == AnimationState::ACTION_IDLE &&
attackTarget->hp > 0.f) {
currentState = AnimationState::ACTION_ATTACK;
resetAnim = true;
}
} else if (currentState == AnimationState::STAND_TO_ACTION ||
currentState == AnimationState::ACTION_IDLE ||
currentState == AnimationState::ACTION_ATTACK) {
currentState = AnimationState::ACTION_TO_STAND;
resetAnim = true;
}
float angleDiff = targetFacingAngle - facingAngle;
while (angleDiff > PI) angleDiff -= 2.f * PI;
while (angleDiff < -PI) angleDiff += 2.f * PI;
const float rotationStep = rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotationStep) {
facingAngle = targetFacingAngle;
faceTargetId.clear();
} else {
facingAngle += angleDiff > 0.f ? rotationStep : -rotationStep;
}
if (!isPlayer && hp > 0.f && battle_state == 0 && maxHomeDrift > 0.f) {
homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (homeDriftCheckTimer <= 0.f) {
homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = homePosition - position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > maxHomeDrift) {
events.navigationRequested = true;
events.navigationTarget = homePosition;
}
}
}
if (resetAnim) {
resetAnim = false;
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
const int previousFrame = static_cast<int>(animationFrame);
animationFrame += static_cast<float>(deltaMs) / 24.f;
if (previousFrame < 19 && static_cast<int>(animationFrame) >= 19 &&
(currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) &&
attackTarget) {
events.attackImpact = true;
events.attackTargetId = attackTargetId;
events.attackDirection = attackTarget->position - position;
events.attackDirection.y() = 0.f;
const float length = events.attackDirection.norm();
if (length > 1e-3f) events.attackDirection /= length;
else events.attackDirection = Eigen::Vector3f::Zero();
}
showWeapon =
(static_cast<int>(animationFrame) >= 20 && currentState == AnimationState::STAND_TO_ACTION) ||
(static_cast<int>(animationFrame) <= 32 && currentState == AnimationState::ACTION_TO_STAND) ||
currentState == AnimationState::ACTION_ATTACK_2 ||
currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_IDLE;
const auto animationIndex = static_cast<std::size_t>(currentState);
const int frameCount = animationIndex < animationFrameCounts.size()
? animationFrameCounts[animationIndex] : 1;
if (frameCount > 1 && static_cast<int>(animationFrame) >= frameCount - 1) {
if (currentState == AnimationState::STAND_TO_ACTION)
currentState = AnimationState::ACTION_IDLE;
else if (currentState == AnimationState::ACTION_TO_STAND)
currentState = AnimationState::STAND;
else if (currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) {
currentState = AnimationState::ACTION_IDLE;
attack = 0;
} else if (currentState == AnimationState::ACTION_TO_DEATH) {
currentState = AnimationState::DEATH_IDLE;
events.deathAnimationComplete = true;
}
const auto nextIndex = static_cast<std::size_t>(currentState);
animationFrame = nextIndex < animationStartFrames.size()
? static_cast<float>(animationStartFrames[nextIndex]) : 0.f;
}
return events;
}
void CharacterState::save(nlohmann::json& out) const
@@ -41,8 +352,8 @@ void CharacterState::save(nlohmann::json& out) const
out["currentState"] = static_cast<int>(currentState);
out["attack_cooldown"] = attack_cooldown;
out["showWeapon"] = showWeapon;
out["attackTargetIndex"] = attackTargetIndex;
out["faceTargetIndex"] = faceTargetIndex;
out["attackTargetId"] = attackTargetId;
out["faceTargetId"] = faceTargetId;
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() };
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() };
out["requestedWalkTarget"] = { requestedWalkTarget.x(), requestedWalkTarget.y(), requestedWalkTarget.z() };
@@ -53,6 +364,7 @@ void CharacterState::save(nlohmann::json& out) const
out["pathWaypoints"] = std::move(wpArr);
out["currentWaypointIndex"] = currentWaypointIndex;
out["homeDriftCheckTimer"] = homeDriftCheckTimer;
out["animationFrame"] = animationFrame;
out["visibleRange"] = visibleRange;
if (!onArrivedCallbackName.empty())
out["onArrivedCallbackName"] = onArrivedCallbackName;
@@ -76,8 +388,8 @@ void CharacterState::load(const nlohmann::json& in)
currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState)));
attack_cooldown = in.value("attack_cooldown", attack_cooldown);
showWeapon = in.value("showWeapon", showWeapon);
attackTargetIndex = in.value("attackTargetIndex", attackTargetIndex);
faceTargetIndex = in.value("faceTargetIndex", faceTargetIndex);
attackTargetId = in.value("attackTargetId", attackTargetId);
faceTargetId = in.value("faceTargetId", faceTargetId);
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition);
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget);
if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3(in["requestedWalkTarget"], requestedWalkTarget);
@@ -89,6 +401,7 @@ void CharacterState::load(const nlohmann::json& in)
}
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
animationFrame = in.value("animationFrame", animationFrame);
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
}
+60 -20
View File
@@ -2,7 +2,9 @@
#include <string>
#include <vector>
#include <unordered_map>
#include <functional>
#include <array>
#include <cstdint>
#include <memory>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -12,6 +14,10 @@ namespace FRG {
constexpr float VISIBLE_RANGE = 6.f;
struct CharacterUpdateContext;
struct CharacterUpdateEvents;
struct LocationNetworkState;
enum class AnimationState {
STAND = 0,
WALK = 1,
@@ -24,6 +30,8 @@ enum class AnimationState {
DEATH_IDLE = 8,
};
// Paths and fixed parameters needed to recreate a Character's visual assets
// from scratch (e.g. after loading a saved game).
struct CharacterCreationInfo {
@@ -45,19 +53,14 @@ struct CharacterCreationInfo {
float weaponMeshScale = 1.0f;
};
// All serializable game state for a character.
// This is a member of Character; rendering-only data lives in Character itself.
// All serializable authoritative state for a character. GameState or
// LocationGameState owns it; rendering-only data lives in Character.
class CharacterState : public ISaveable {
public:
// Special index values for attackTargetIndex / faceTargetIndex
static constexpr int kNoTarget = -1; // no target (was nullptr)
static constexpr int kPlayerIndex = -2; // refers to Location::player
// Distance below which the character is considered to have reached its target.
static constexpr float kWalkThreshold = 0.05f;
// --- Identity ---
std::string npcId;
std::string npcName;
bool isPlayer = false;
bool enabled = true;
@@ -84,9 +87,11 @@ public:
// Weapon visibility toggle (changes during animation)
bool showWeapon = true;
// --- Index-based character references (replace raw pointers) ---
int attackTargetIndex = kNoTarget;
int faceTargetIndex = kNoTarget;
// --- Stable character references ---
// Empty means no target. "player" is the campaign player; NPC IDs are
// location-local and validated while loading location content.
std::string attackTargetId;
std::string faceTargetId;
// --- Home position for drift recovery ---
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero();
@@ -97,6 +102,13 @@ public:
float slowMoActiveTime = 6.0f;
float slowMoTransitionTime = 0.5f;
// --- Authoritative logical animation clock ---
// Frame counts are immutable metadata copied from loaded clips during
// character construction. Simulation advances animationFrame itself.
float animationFrame = 0.0f;
std::array<int, 9> animationStartFrames = {{0, 0, 0, 0, 0, 0, 0, 0, 0}};
std::array<int, 9> animationFrameCounts = {{1, 1, 1, 1, 1, 1, 1, 1, 1}};
// --- Movement / visual parameters ---
float walkSpeed = 3.0f;
float rotationSpeed = 4.0f;
@@ -115,30 +127,58 @@ public:
// Internal drift-check timer (serializable so it survives save/load).
float homeDriftCheckTimer = 5.0f;
// --- Non-serializable callbacks (re-wired after each load) ---
std::function<void()> onArrivedCallback;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, int)> onHpChanged;
// Global Lua function name backing onArrivedCallback, if it was sourced from a
// script (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback
// can be re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the character reaches the final waypoint.
// The name is serialized, so no runtime callback needs to be rehydrated.
std::string onArrivedCallbackName;
// --- Asset / creation info (used to reload visuals from a saved state) ---
CharacterCreationInfo creationInfo;
// --- Pure-state methods ---
void configureAsPlayer();
bool isMoving() const;
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
Eigen::Vector3f getCurrentNavigationTarget() const;
void stopInPlace();
void setTarget(const Eigen::Vector3f& target,
std::string callbackName,
const LocationNetworkState& location,
const std::string& entityId);
void forceReplan(const LocationNetworkState& location,
const std::string& entityId);
void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath();
void tryAttackCombo();
float getHp() const { return hp; }
void setHp(float newHp);
CharacterUpdateEvents update(std::int64_t deltaMs, const CharacterUpdateContext& context);
void setAnimationMetadata(AnimationState state, int startFrame, int frameCount);
// --- Serialisation (runtime mutable fields only; creation info is reloaded from config) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
};
using PlayerStateMap =
std::unordered_map<std::string, std::shared_ptr<CharacterState>>;
struct CharacterObservation {
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float hp = 0.f;
};
struct CharacterUpdateContext {
const CharacterObservation* attackTarget = nullptr;
const CharacterObservation* faceTarget = nullptr;
};
struct CharacterUpdateEvents {
bool attackImpact = false;
bool deathAnimationComplete = false;
bool navigationRequested = false;
Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero();
std::string arrivedCallbackName;
std::string attackTargetId;
Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero();
};
} // namespace FRG
+2 -2
View File
@@ -21,8 +21,8 @@ int Environment::width = CONST_DEFAULT_WIDTH;
int Environment::height = CONST_DEFAULT_HEIGHT;
float Environment::zoom = DEFAULT_ZOOM;
//bool Environment::enableLogging = false;
bool Environment::enableLogging = true;
bool Environment::enableLogging = false;
//bool Environment::enableLogging = true;
SDL_Window* Environment::window = nullptr;
+339 -198
View File
@@ -1,4 +1,5 @@
#include "Game.h"
#include "game_server/NetworkProtocol.h"
#include "AnimatedModel.h"
#include "Localization.h"
#include "utils/Utils.h"
@@ -13,6 +14,7 @@
#include <cmath>
#include <algorithm>
#include <functional>
#include <type_traits>
#ifdef __ANDROID__
#include <android/log.h>
#endif
@@ -76,16 +78,19 @@ namespace FRG
#endif
Location* Game::currentLocation() const {
if (gameState.currentLocationName.empty()) return nullptr;
auto it = gameState.locations.find(gameState.currentLocationName);
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
const auto state = stateSnapshot();
if (!state || state->currentLocationId.empty()) return nullptr;
return gameServerHost.runtimeLocation(state->currentLocationId);
}
Game::Game()
: newTickCount(0)
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, internalGameServer()
, localGameServerHost(internalGameServer)
, gameServer(internalGameServer)
, gameServerHost(localGameServerHost)
, menuManager(renderer, gameServer, *audioPlayer)
, lastTickCount(0)
, audioPlayer(std::make_unique<AudioPlayerAsync>())
, menuManager(renderer, gameState, *audioPlayer)
{
}
@@ -357,19 +362,6 @@ namespace FRG
CampaignRegistry::instance().loadFromJson("resources/config/campaigns.json", CONST_ZIP_FILE);
loadSteps.push([this]() {
// Wire inventory callbacks: tutorial tracking + toast notifications.
gameState.inventory.onItemAdded = [this](const std::string& itemId) {
menuManager.onItemPickedUp(itemId);
const Item* item = ItemRegistry::instance().findById(itemId);
if (item) menuManager.showToast(menuManager.toastTextureItemAdded, item->name);
audioPlayer->playSoundAsync("resources/audio/831642__ienba__generic-game-pick-up.ogg");
};
gameState.inventory.onItemRemoved = [this](const std::string& itemId) {
const Item* item = ItemRegistry::instance().findById(itemId);
if (item) menuManager.showToast(menuManager.toastTextureItemRemoved, item->name);
audioPlayer->playSoundAsync("resources/audio/831642__ienba__generic-game-pick-up.ogg");
};
// Wire phone dialogue start function so MenuManager can trigger dialogues.
menuManager.startDialogueFunc = [this](const std::string& id) {
if (currentLocation()) currentLocation()->dialogueSystem.startDialogue(id);
@@ -391,15 +383,11 @@ namespace FRG
menuManager.skipCutsceneFunc = [this]() {
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
};
menuManager.callTaxiFunc = [this]() {
if (currentLocation()) currentLocation()->onTaxiCalled();
};
});
loadSteps.push([this]() {
try {
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
menuManager.setup(CONST_ZIP_FILE);
logger() << "UI loaded successfully" << std::endl;
}
catch (const std::exception& e) {
@@ -414,7 +402,8 @@ namespace FRG
performResetToInitialState();
applyTutorialSetting();
const CampaignConfig& campaign = activeCampaign();
gameState.currentLocationName = campaign.startLocation;
server().submit(
SetCurrentLocationCommand{campaign.startLocation});
menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state
if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback();
audioPlayer->crossFadeMusicAsync(campaign.startMusic);
@@ -505,7 +494,10 @@ namespace FRG
}
const CampaignConfig& Game::activeCampaign() const {
const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState.campaignId);
const auto state = stateSnapshot();
const CampaignConfig* campaign = state
? CampaignRegistry::instance().find(state->campaignId)
: nullptr;
if (!campaign) campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId);
if (!campaign) {
static const CampaignConfig empty;
@@ -515,7 +507,8 @@ namespace FRG
}
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
return player && loadedCampaignId == campaignId && gameState.locations.count(locationId) > 0;
return player && loadedCampaignId == campaignId
&& gameServerHost.hasRuntimeLocation(locationId);
}
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
@@ -555,48 +548,83 @@ namespace FRG
void Game::loadCampaign(const std::string& campaignId) {
loadedCampaignId = campaignId;
gameState.campaignId = campaignId;
gameState.storedLocationStates.clear();
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
const CampaignConfig* campaign =
CampaignRegistry::instance().find(campaignId);
if (!campaign) return;
#if defined(NETWORK)
remoteCampaignActive = campaignId == "battle_zone_multiplayer";
remoteInitialStateBound = false;
if (remoteCampaignActive) {
logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl;
websocketGameClient = std::make_unique<WebsocketGameClient>();
remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*websocketGameClient);
gameServer.use(*websocketGameClient);
gameServerHost.use(*remoteGameServerHost);
logger() << "[network] Remote game server routes installed"
<< std::endl;
} else {
logger() << "[network] Campaign '" << campaignId
<< "' uses the in-process server" << std::endl;
gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost);
}
#else
remoteCampaignActive = false;
remoteInitialStateBound = false;
logger() << "[network] Networking was not compiled into this client; "
"campaign '" << campaignId << "' uses the in-process server"
<< std::endl;
#endif
gameServerHost.initializeCampaign(
campaignId, campaign->questsPath, CONST_ZIP_FILE);
// The player stays loaded for the whole campaign; locations come and go around him.
player = Location::createPlayer(renderer, audioPlayer.get());
player = Location::createPlayer(
renderer, audioPlayer.get(), server(),
server().localPlayerId());
if (player)
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
}
void Game::unloadCampaign() {
unloadLocation(false);
player.reset();
loadedCampaignId.clear();
gameState.storedLocationStates.clear();
gameServerHost.clearCampaign();
#if defined(NETWORK)
gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost);
remoteGameServerHost.reset();
websocketGameClient.reset();
#endif
remoteCampaignActive = false;
remoteInitialStateBound = false;
renderer.textureManager.UnloadUnused(); // the player's textures
}
void Game::loadLocation(const std::string& locationId) {
const LocationConfig* config = activeCampaign().findLocation(locationId);
if (!config) {
std::cerr << "[campaign] Campaign '" << gameState.campaignId << "' has no location '"
const auto state = stateSnapshot();
std::cerr << "[campaign] Campaign '"
<< (state ? state->campaignId : std::string()) << "' has no location '"
<< locationId << "'" << std::endl;
return;
}
SDL_PumpEvents();
const bool initializeLocation = gameServerHost.prepareLocation(*config);
std::shared_ptr<Location> loc = createLocation(*config);
wireLocation(loc.get());
if (shadowMap) loc->shadowMap = shadowMap;
if (initializeLocation)
loc->scriptEngine.callLocationInitCallback();
// Back to how the player left it (or to the start state's snapshot of it).
auto stored = gameState.storedLocationStates.find(locationId);
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
gameState.locations[locationId] = loc;
gameServerHost.bindRuntimeLocation(locationId, loc);
}
void Game::unloadLocation(bool storeState) {
for (auto& [name, loc] : gameState.locations) {
if (!storeState) continue;
nlohmann::json locJson;
loc->save(locJson);
gameState.storedLocationStates[name] = std::move(locJson);
}
gameState.locations.clear();
gameServerHost.clearRuntimeLocations(storeState);
// Textures only the location's models and characters used are freed now.
renderer.textureManager.UnloadUnused();
}
@@ -614,61 +642,39 @@ namespace FRG
}
// Runs from inside the current location's update(); the location itself is
// released by a later loading step.
enterLocationThen(gameState.campaignId, destName, [this, destName, destPos, destRotY]() {
gameState.currentLocationName = destName;
const auto state = stateSnapshot();
if (!state) return;
enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() {
server().submit(SetCurrentLocationCommand{destName});
Location* loc = currentLocation();
if (!loc) return;
if (loc->player) {
loc->player->state.position = destPos;
loc->player->setTarget(destPos);
loc->player->stopInPlace();
loc->player->state.facingAngle = destRotY;
loc->player->state.targetFacingAngle = destRotY;
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
workingState.position = destPos;
workingState.facingAngle = destRotY;
workingState.targetFacingAngle = destRotY;
});
loc->stopCharacter(*loc->player);
}
loc->state.cameraAzimuth = destRotY;
loc->state.isDarklands = gameState.isDarklands;
loc->state.isNight = gameState.isNight;
loc->state.isDawn = gameState.isDawn;
loc->localState().cameraAzimuth = destRotY;
loc->scriptEngine.callLocationEnterCallback();
//Reset taxi
loc->state.taxiCar = loc->state.taxiCarInitialState;
loc->state.taxiArrivedCallbackCalled = false;
gameState.taxiIsCalled = false;
server().submit(ResetTaxiCommand{destName});
server().submit(SetTaxiCalledCommand{false});
menuManager.onLocationChanged(destName);
});
}
void Game::performFullReset() {
gameState.currentLocationName.clear();
gameState.isDarklands = false;
gameState.isNight = false;
gameState.isDawn = false;
gameState.playerHp = 200.f;
gameState.playerMaxHp = 200.f;
gameState.money = 5500;
gameState.globalInts.clear();
gameState.globalFloats.clear();
gameState.globalFloats["player_hp"] = 200;
gameState.tutorialStep = TutorialStep::AdvanceDialogue;
gameState.tutorialPhoneChatScreenOpened = false;
gameState.tutorialJournalScreenOpened = false;
gameState.tutorialNeedOpenTaxiScreen = false;
gameState.tutorialTaxiHintDone = false;
for (int i = 0; i < 3; ++i) {
gameState.chatUnread[i] = true;
gameState.chatPreviewMsg[i].clear();
gameState.chatHistory[i].clear();
}
gameState.inventory.clear();
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
void Game::performFullReset() {
// Rebuild the loaded location from its config; the others have no stored state
// any more, so they are built fresh when the player first goes there.
gameState.storedLocationStates.clear();
std::vector<std::string> loadedIds;
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
std::vector<std::string> loadedIds =
gameServerHost.loadedRuntimeLocationIds();
gameServerHost.resetCampaign(
activeCampaign().questsPath, CONST_ZIP_FILE);
unloadLocation(false);
for (const std::string& id : loadedIds) loadLocation(id);
@@ -676,6 +682,7 @@ namespace FRG
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
// Re-comment before shipping.
//saveInitialState();
}
void Game::performResetToInitialState() {
@@ -698,15 +705,8 @@ namespace FRG
if (!content.empty()) {
try {
nlohmann::json root = nlohmann::json::parse(content);
const std::string campaignId = gameState.campaignId;
// GameState::load() merges into existing maps rather than replacing them,
// so any Lua globals set during play that aren't in the pristine snapshot
// must be cleared explicitly before loading it.
gameState.globalInts.clear();
gameState.globalFloats.clear();
gameState.load(root); // applies to the loaded location in place and stores
gameServerHost.load(root); // applies to the loaded location in place and stores
// the other locations' states for when they load.
gameState.campaignId = campaignId; // the snapshot may predate campaigns
return;
} catch (const std::exception& e) {
logger() << "[reset] Failed to parse " << path << ": " << e.what()
@@ -724,12 +724,13 @@ namespace FRG
// (if the campaign has one); a disabled tutorial is simply one that is already completed.
void Game::applyTutorialSetting() {
if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting)
gameState.tutorialStep = TutorialStep::Completed;
server().submit(
SetTutorialStepCommand{TutorialStep::Completed});
}
void Game::saveInitialState() {
nlohmann::json root;
gameState.save(root);
gameServerHost.save(root);
// Written next to the executable; copy it over the campaign's start-state file.
const std::string startStatePath = activeCampaign().startStatePath;
const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1);
@@ -752,7 +753,7 @@ namespace FRG
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
params.npcsJsonPath = config.npcsJsonPath;
params.dialoguesJsonPath = config.dialoguesJsonPath;
params.navigationJsonPaths = config.navigationJsonPaths;
//params.navigationJsonPaths = config.navigationJsonPaths;
params.scriptPath = config.scriptPath;
params.teleportsJsonPath = config.teleportsJsonPath;
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
@@ -760,27 +761,30 @@ namespace FRG
params.playerPosition = config.playerPosition;
params.player = player;
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
loc->setup(params, &gameState.questJournal, audioPlayer.get());
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
auto loc = std::make_shared<Location>(
renderer, server(), config.id);
gameServerHost.setupRuntimeLocation(
config.id, loc, std::move(params), audioPlayer.get());
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
gameState.isNight = isNight;
gameState.isDawn = isDawn;
const auto state = stateSnapshot();
if (!state) return;
server().submit(SetWorldModeCommand{
state->isDarklands, isNight, isDawn});
};
loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
loc->requestReturnToMainMenu = [this]() {
gameState.currentLocationName.clear();
server().submit(SetCurrentLocationCommand{std::string()});
menuManager.showMenuAfterGameCompleted();
};
loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) {
menuManager.setChatUnread(idx, unread, msg);
};
for (auto& npc : loc->npcs) {
if (npc && npc->state.canAttack) {
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
auto state = npc ? npc->stateSnapshot() : nullptr;
if (state && state->canAttack) {
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
}
}
loc->requestDarklandsPlayBattleMusic = [this]() {
@@ -792,26 +796,6 @@ namespace FRG
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
};
if (config.lockInteractiveObjectsDuringTutorial) {
// Objects can be picked up once the tutorial reaches "pick up an item".
loc->areInteractiveObjectsLocked = [this]() {
return gameState.tutorialStep < TutorialStep::PickUpItem;
};
}
if (config.taxi) {
const LocationConfig::Taxi& taxi = *config.taxi;
loc->state.taxiCarTriggerPosition = taxi.triggerPosition;
loc->state.taxiCarWaypoints = taxi.waypoints;
loc->state.taxiCarInitialState.position = taxi.initialPosition;
loc->state.taxiCarInitialState.rotation = taxi.initialRotation;
loc->state.taxiCarInitialState.mode = NpcCar::Mode::NONE;
loc->state.taxiCarInitialState.velocity = 0.f;
loc->state.taxiCarInitialState.steeringAngle = 0.f;
loc->state.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
loc->state.taxiCar = loc->state.taxiCarInitialState;
}
return loc;
}
@@ -826,20 +810,16 @@ namespace FRG
teleportPlayer(destName, destPos, destRotY);
};
// Share the global int store with all dialogue runtimes so flags set via
// dialogue JSON and via Lua set_dialogue_flag all see the same state.
{
loc->dialogueSystem.setGlobalFlagStore(&gameState.globalInts);
}
// Wire tutorial advance callbacks.
// completeTutorialStep() only acts on the current step, so sharing is safe.
{
loc->dialogueSystem.setOnDialogueAdvanced([this]() {
gameState.completeTutorialStep(TutorialStep::AdvanceDialogue);
server().submit(
CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue});
});
loc->onPlayerFloorWalk = [this]() {
gameState.completeTutorialStep(TutorialStep::Walk);
server().submit(
CompleteTutorialStepCommand{TutorialStep::Walk});
menuManager.onPlayerStartedWalking();
};
}
@@ -866,24 +846,34 @@ namespace FRG
if (loc->player) {
// Raw pointer: the player belongs to the location (and outlives it), so a
// shared_ptr here would keep unloaded locations alive. Rewired on every load.
loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) {
loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) {
menuManager.updateHealthBar(hp, maxHp);
if (attackerIndex != CharacterState::kNoTarget) {
if (!attackerId.empty()) {
//Someone attacked, cancel dialogue and agro the player
if (loc->dialogueSystem.isDialogueActive()) {
loc->dialogueSystem.stopDialogue();
}
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
((loc->player->state.attackTargetIndex >= 0) &&
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
bool currentTargetDead = false;
auto playerState = loc->player->stateSnapshot();
if (!playerState) return;
for (const auto& npc : loc->npcs) {
if (npc && npc->entityId() == playerState->attackTargetId) {
auto npcState = npc->stateSnapshot();
currentTargetDead = !npcState || npcState->hp <= 0.f;
break;
}
}
if ((playerState->attackTargetId.empty() || currentTargetDead)
&&
loc->player->isMoving() == false
&&
loc->player->getHp() > 0) {
loc->player->state.attackTargetIndex = attackerIndex;
server().submit(SetCharacterTargetCommand{
loc->locationId(),
server().localPlayerId(), attackerId});
}
}
@@ -905,7 +895,9 @@ namespace FRG
CheckGlError(__FILE__, __LINE__);
logger() << "Shadow map initialized" << std::endl;
for (auto& [name, loc] : gameState.locations) loc->shadowMap = shadowMap;
for (const std::string& id : gameServerHost.loadedRuntimeLocationIds())
if (Location* loc = gameServerHost.runtimeLocation(id))
loc->shadowMap = shadowMap;
}
void Game::destroyShadowMapAndProparateToLocations()
@@ -915,7 +907,9 @@ namespace FRG
shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
for (auto& [name, loc] : gameState.locations) loc->shadowMap.reset();
for (const std::string& id : gameServerHost.loadedRuntimeLocationIds())
if (Location* loc = gameServerHost.runtimeLocation(id))
loc->shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
}
@@ -988,16 +982,12 @@ namespace FRG
Location* loc = currentLocation();
if (loc)
{
// Sync global flags so Location's draw functions see them.
loc->state.isDarklands = gameState.isDarklands;
loc->state.isNight = gameState.isNight;
loc->state.isDawn = gameState.isDawn;
if (gameState.isDarklands) {
const auto state = stateSnapshot();
if (state && state->isDarklands) {
loc->drawGameDarklands();
CheckGlError(__FILE__, __LINE__);
}
else if (gameState.isNight) {
else if (state && state->isNight) {
loc->drawGameNight();
CheckGlError(__FILE__, __LINE__);
}
@@ -1142,7 +1132,7 @@ namespace FRG
int64_t Game::getSyncTimeMs() {
int64_t localNow = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
std::chrono::steady_clock::now().time_since_epoch()).count();
return localNow;
}
@@ -1151,21 +1141,15 @@ namespace FRG
if (lastTickCount == 0) {
lastTickCount = getSyncTimeMs();
lastTickCount = (lastTickCount / 50) * 50;
return;
}
newTickCount = getSyncTimeMs();
newTickCount = (newTickCount / 50) * 50;
if (newTickCount - lastTickCount > CONST_TIMER_INTERVAL) {
int64_t delta = newTickCount - lastTickCount;
lastTickCount = newTickCount;
const int64_t now = getSyncTimeMs();
const int64_t delta =
(std::min)(now - lastTickCount,
static_cast<int64_t>(CONST_MAX_TIME_INTERVAL));
lastTickCount = now;
if (delta > 0) {
updateDarklandsFlash(delta);
menuManager.uiManager.update(static_cast<float>(delta));
@@ -1175,14 +1159,113 @@ namespace FRG
if (!menuManager.isMainMenuOpen())
menuManager.update(static_cast<float>(delta));
if (loadingCompleted && currentLocation())
if (loadingCompleted)
{
currentLocation()->update(delta);
if (remoteCampaignActive) {
// Remote authoritative time comes only from contiguous server ticks.
gameServerHost.update(delta);
#if defined(NETWORK)
// The connection-assigned slot is known only after initial_state.
// Always rebuild once: replica_.load() replaces authoritative
// location state even when the assigned ID remains "player".
if (websocketGameClient && websocketGameClient->initialized() &&
(!remoteInitialStateBound || !player ||
player->entityId() != server().localPlayerId())) {
const auto assignedState = stateSnapshot();
const std::string locationId = assignedState
? assignedState->currentLocationId : std::string();
unloadLocation(false);
player = Location::createPlayer(
renderer, audioPlayer.get(), server(),
server().localPlayerId());
if (player)
player->onDeathAnimComplete =
[this]() { startDarklandsTransition(); };
if (!locationId.empty()) loadLocation(locationId);
remoteInitialStateBound = true;
logger() << "[network] Runtime rebound to assigned player '"
<< server().localPlayerId() << "'" << std::endl;
}
#endif
} else {
simulationAccumulatorMs += delta;
while (simulationAccumulatorMs >= kSimulationTickMs) {
gameServerHost.update(kSimulationTickMs);
simulationAccumulatorMs -= kSimulationTickMs;
}
}
#if defined(NETWORK)
if (remoteCampaignActive && websocketGameClient &&
websocketGameClient->connectionFailed()) {
logger() << "[network] Connection failed; returning to campaign selection"
<< std::endl;
unloadCampaign();
menuManager.showMainMenu();
menuManager.showCampaignScreen();
return;
}
#endif
processServerEvents();
}
}
}
void Game::processServerEvents()
{
for (GameServerEvent& event : server().drainEvents()) {
std::visit([this](const auto& value) {
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, InventoryItemAddedEvent>) {
menuManager.onItemPickedUp(value.itemId);
const Item* item =
ItemRegistry::instance().findById(value.itemId);
if (item)
menuManager.showToast(
menuManager.toastTextureItemAdded, item->name);
audioPlayer->playSoundAsync(
"resources/audio/831642__ienba__generic-game-pick-up.ogg");
} else if constexpr (std::is_same_v<T, InventoryItemRemovedEvent>) {
const Item* item =
ItemRegistry::instance().findById(value.itemId);
if (item)
menuManager.showToast(
menuManager.toastTextureItemRemoved, item->name);
audioPlayer->playSoundAsync(
"resources/audio/831642__ienba__generic-game-pick-up.ogg");
} else if constexpr (std::is_same_v<T, Quest::QuestEvent>) {
const auto state = stateSnapshot();
if (!state) return;
auto quest = std::find_if(
state->quests.begin(), state->quests.end(),
[&value](const Quest::QuestState& candidate) {
return candidate.definition.id == value.questId;
});
if (quest == state->quests.end()) return;
switch (value.type) {
case Quest::QuestEventType::Unlocked:
menuManager.showToast(
menuManager.toastTextureQuestNew,
quest->definition.title);
break;
case Quest::QuestEventType::Completed:
menuManager.showToast(
menuManager.toastTextureQuestCompleted,
quest->definition.title);
break;
case Quest::QuestEventType::Failed:
menuManager.showToast(
menuManager.toastTextureQuestFailed,
quest->definition.title);
break;
case Quest::QuestEventType::ObjectiveCompleted:
break;
}
}
}, event);
}
}
void Game::render() {
FRG::CheckGlError(__FILE__, __LINE__);
@@ -1228,6 +1311,7 @@ namespace FRG
}
void Game::update() {
processServerEvents();
SDL_Event event;
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
while (SDL_PollEvent(&event)) {
@@ -1332,8 +1416,9 @@ namespace FRG
}
else if (event.button.button == SDL_BUTTON_RIGHT
&& event.type == SDL_MOUSEBUTTONUP
&& editorMode == EditorMode::Navigation
&& currentLocation()) {
&& currentLocation()
&& currentLocation()->editorMode == EditorMode::Navigation
) {
currentLocation()->editor.handleRightClick();
}
}
@@ -1361,7 +1446,8 @@ namespace FRG
}
//logger() << "Current zoom: " << Environment::zoom << std::endl;
// Tutorial: any mouse-wheel scroll counts as the "zoom gesture".
gameState.completeTutorialStep(TutorialStep::Zoom);
server().submit(
CompleteTutorialStepCommand{TutorialStep::Zoom});
}
if (event.type == SDL_KEYDOWN && event.key.repeat == 0) {
@@ -1378,15 +1464,29 @@ namespace FRG
}
if (event.key.keysym.sym == SDLK_1)
{
startNightTransition();
currentLocation()->switchNavigation(0);
//startNightTransition();
}
if (event.key.keysym.sym == SDLK_2)
{
startDarklandsTransition();
currentLocation()->switchNavigation(1);
//startDarklandsTransition();
}
if (event.key.keysym.sym == SDLK_3)
{
logger() << "Player position: " << currentLocation()->player->state.position.transpose() << std::endl;
currentLocation()->switchNavigation(2);
}
if (event.key.keysym.sym == SDLK_n)
{
if (currentLocation()->editorMode == EditorMode::None)
{
currentLocation()->editorMode = EditorMode::Navigation;
}
else
{
currentLocation()->editorMode = EditorMode::None;
}
}
}
@@ -1438,12 +1538,18 @@ namespace FRG
if (!currentLocation()) return;
if (currentLocation()->player) {
currentLocation()->player->state.slowMoTimeRemaining = currentLocation()->player->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*currentLocation()->player, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
for (auto& npc : currentLocation()->npcs) {
if (npc) {
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*npc, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
}
}
@@ -1460,8 +1566,9 @@ namespace FRG
currentLocation()->lastMouseY = eventY;
// Snapshot current angles so we can measure how far the user rotates.
dragStartAzimuth = currentLocation()->state.cameraAzimuth;
dragStartInclination = currentLocation()->state.cameraInclination;
const LocationLocalState& state = currentLocation()->localState();
dragStartAzimuth = state.cameraAzimuth;
dragStartInclination = state.cameraInclination;
}
}
@@ -1521,7 +1628,8 @@ namespace FRG
// Tutorial: detect a significant pinch-zoom (≥ 2 zoom units).
if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) {
gameState.completeTutorialStep(TutorialStep::Zoom);
server().submit(
CompleteTutorialStepCommand{TutorialStep::Zoom});
}
}
@@ -1664,12 +1772,16 @@ namespace FRG
// ~0.15 rad (≈8.6°) per axis ensures the user intentionally panned in 2D,
// not just nudged a single axis by accident.
static constexpr float TUTORIAL_ROTATION_THRESHOLD = 0.15f;
const auto game = stateSnapshot();
if (cameraDragging
&& gameState.tutorialStep == TutorialStep::RotateCamera) {
float deltaAz = std::abs(currentLocation()->state.cameraAzimuth - dragStartAzimuth);
float deltaInc = std::abs(currentLocation()->state.cameraInclination - dragStartInclination);
&& game
&& game->tutorialStep == TutorialStep::RotateCamera) {
const LocationLocalState& state = currentLocation()->localState();
float deltaAz = std::abs(state.cameraAzimuth - dragStartAzimuth);
float deltaInc = std::abs(state.cameraInclination - dragStartInclination);
if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) {
gameState.completeTutorialStep(TutorialStep::RotateCamera);
server().submit(
CompleteTutorialStepCommand{TutorialStep::RotateCamera});
}
}
}
@@ -1677,13 +1789,14 @@ namespace FRG
bool Game::startDarklandsTransition()
{
if (!gameState.isNight)
const auto state = stateSnapshot();
if (!state || !state->isNight)
{
currentLocation()->dialogueSystem.startDialogue("darklands_day_dialog001");
return false;
}
if (gameState.isDawn)
if (state->isDawn)
{
currentLocation()->dialogueSystem.startDialogue("darklands_morning_dialog001");
return false;
@@ -1717,11 +1830,13 @@ namespace FRG
if (darklandsFlashFadingIn) {
darklandsFlashAlpha = min(darklandsFlashAlpha + step, 1.0f);
if (darklandsFlashAlpha >= 1.0f) {
const auto before = stateSnapshot();
if (!before) return;
if (isNightTransition) {
gameState.isNight = !gameState.isNight;
server().submit(SetWorldModeCommand{
before->isDarklands, !before->isNight, before->isDawn});
if (currentLocation())
{
currentLocation()->state.isNight = gameState.isNight;
currentLocation()->scriptEngine.callTriggerNightEnterCallback();
}
@@ -1729,12 +1844,15 @@ namespace FRG
menuManager.lastMusicTrack = "music/bishkek night.ogg";
} else {
gameState.isDarklands = !gameState.isDarklands;
server().submit(SetWorldModeCommand{
!before->isDarklands, before->isNight, before->isDawn});
const auto after = stateSnapshot();
if (!after) return;
// Change HUD
menuManager.setDarklandsMode(gameState.isDarklands);
menuManager.setDarklandsMode(after->isDarklands);
if (gameState.isDarklands)
if (after->isDarklands)
{
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
@@ -1746,8 +1864,7 @@ namespace FRG
}
if (currentLocation()) {
currentLocation()->state.isDarklands = gameState.isDarklands;
if (gameState.isDarklands)
if (after->isDarklands)
currentLocation()->scriptEngine.callDarklandsEnterCallback();
else
currentLocation()->scriptEngine.callDarklandsExitCallback();
@@ -1816,11 +1933,18 @@ namespace FRG
if (content.empty()) return {};
try {
nlohmann::json root = nlohmann::json::parse(content);
if (root.value("persistenceVersion", -1) !=
kSinglePlayerPersistenceVersion) return {};
const auto& networkState = root.at("networkState");
if (networkState.value("schemaVersion", -1) !=
GameState::kSchemaVersion) return {};
SaveSlotInfo info;
info.empty = false;
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
info.locationName = root.value("currentLocationName", "");
info.savedAt = root.value("savedAt", "");
info.campaignId =
networkState.at("campaignId").get<std::string>();
info.locationName =
networkState.at("currentLocationId").get<std::string>();
info.savedAt = networkState.value("savedAt", "");
return info;
} catch (...) {
return {};
@@ -1830,7 +1954,7 @@ namespace FRG
void Game::saveGame(int slot)
{
nlohmann::json root;
gameState.save(root);
gameServerHost.save(root);
std::string path = "save_slot" + std::to_string(slot) + ".json";
/*std::ofstream file(path);
@@ -1858,11 +1982,26 @@ namespace FRG
return;
}
// The save's campaign and location may not be loaded yet; the state is applied once they are.
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
const std::string locationId = root.value("currentLocationName", std::string());
if (root.value("persistenceVersion", -1) !=
kSinglePlayerPersistenceVersion ||
!root.contains("networkState") ||
root["networkState"].value("schemaVersion", -1) !=
GameState::kSchemaVersion) {
std::cerr << "[save] Unsupported save schema in " << path << std::endl;
return;
}
const auto& networkState = root["networkState"];
const std::string campaignId =
networkState.value("campaignId", std::string());
const std::string locationId =
networkState.value("currentLocationId", std::string());
const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId);
if (!campaign || !campaign->findLocation(locationId)) {
std::cerr << "[save] Unknown campaign/location in " << path << std::endl;
return;
}
enterLocationThen(campaignId, locationId, [this, root]() {
gameState.load(root);
gameServerHost.load(root);
playMusicForLoadedGame();
menuManager.enterGameplay(true);
});
@@ -1870,19 +2009,21 @@ namespace FRG
void Game::playMusicForLoadedGame()
{
if (gameState.isDarklands)
const auto state = stateSnapshot();
if (!state) return;
if (state->isDarklands)
{
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
}
else if (gameState.isNight)
else if (state->isNight)
{
audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg");
menuManager.lastMusicTrack = "music/bishkek night.ogg";
}
else
{
if (gameState.currentLocationName == "location_dorm") {
if (state->currentLocationId == "location_dorm") {
audioPlayer->crossFadeMusicAsync("music/obshaga.ogg");
menuManager.lastMusicTrack = "music/obshaga.ogg";
}
+33 -11
View File
@@ -21,7 +21,14 @@
#include <unordered_set>
#include "Location.h"
#include "AudioPlayerAsync.h"
#include "GameState.h"
#include "game_server/InternalGameServer.h"
#include "game_server/LocalGameServerHost.h"
#include "game_server/GameServerRouter.h"
#include "game_server/GameServerHostRouter.h"
#if defined(NETWORK)
#include "game_server/WebsocketGameClient.h"
#include "game_server/RemoteGameServerHost.h"
#endif
namespace FRG {
struct LocationConfig;
@@ -73,9 +80,7 @@ namespace FRG {
// content reload, don't have a meaningful denominator, so the bar is hidden.
bool showLoadingProgressBar = true;
GameState gameState;
EditorMode editorMode = EditorMode::None;
//EditorMode editorMode = EditorMode::None;
// Returns false if a transition is already in progress.
bool startDarklandsTransition();
@@ -83,6 +88,22 @@ namespace FRG {
std::unique_ptr<AudioPlayerAsync> audioPlayer;
private:
InternalGameServer internalGameServer;
LocalGameServerHost localGameServerHost;
#if defined(NETWORK)
std::unique_ptr<WebsocketGameClient> websocketGameClient;
std::unique_ptr<RemoteGameServerHost> remoteGameServerHost;
#endif
GameServerRouter gameServer;
GameServerHostRouter gameServerHost;
bool remoteCampaignActive = false;
bool remoteInitialStateBound = false;
public:
IGameServer& server() { return gameServer; }
const IGameServer& server() const { return gameServer; }
MenuManager menuManager;
void activateSlowMoEffect();
@@ -123,15 +144,16 @@ namespace FRG {
Location* currentLocation() const;
std::shared_ptr<const GameSnapshot> stateSnapshot() const {
return gameServer.snapshot();
}
void saveGame(int slot);
void loadGame(int slot);
void playMusicForLoadedGame();
SaveSlotInfo readSlotInfo(int slot) const;
// Loading model: one campaign at a time, and within it only the location the player
// is in (gameState.locations). Leaving a location stores its state in
// gameState.storedLocationStates and releases it; the player is created once per
// campaign and shared by its locations.
// Loading model: one campaign at a time. InternalGameServer privately owns the
// one live Location while GameState retains pure value state for every location.
std::string loadedCampaignId;
std::shared_ptr<Character> player;
@@ -146,7 +168,7 @@ namespace FRG {
void unloadCampaign();
// Builds the campaign's location and restores its stored state, if any.
void loadLocation(const std::string& locationId);
// Releases the loaded location, keeping its state in storedLocationStates if asked.
// Releases the loaded runtime location, retaining its value state when requested.
void unloadLocation(bool storeState);
// Teleport zones: moves the player to another location of the campaign.
void teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY);
@@ -174,6 +196,7 @@ namespace FRG {
int64_t getSyncTimeMs();
void processTickCount();
void processServerEvents();
void drawScene();
void drawUI();
@@ -230,13 +253,12 @@ namespace FRG {
*/
//void drawMobilePortraitOverlay();
int64_t newTickCount;
int64_t lastTickCount;
int64_t simulationAccumulatorMs = 0;
int lastDrawableW = -1;
int lastDrawableH = -1;
static const size_t CONST_TIMER_INTERVAL = 10;
static const size_t CONST_MAX_TIME_INTERVAL = 1000;
};
+74 -44
View File
@@ -1,24 +1,12 @@
#include "GameState.h"
#include "utils/Utils.h"
#include <ctime>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace FRG {
// Saves made before the tutorial rework kept the dorm and the university tutorials
// as two state machines (tutorialStep 0..6 + uniIntTutorialState 0..4); map them
// onto the single TutorialStep sequence.
static TutorialStep migrateLegacyTutorialStep(int dormStep, int uniIntState)
{
if (dormStep <= 4) return static_cast<TutorialStep>(dormStep); // Step0..Step4 kept their values
switch (uniIntState) {
case 2: return TutorialStep::DarklandsHealth; // DarklandsActive
case 3: return TutorialStep::DarklandsAttack; // DarklandsStep13
case 4: return TutorialStep::Completed; // DarklandsFull
default: return TutorialStep::EnterDarklands; // Step10 / Step11
}
}
bool GameState::completeTutorialStep(TutorialStep step)
{
if (tutorialStep != step || step == TutorialStep::Completed) return false;
@@ -26,8 +14,29 @@ bool GameState::completeTutorialStep(TutorialStep step)
return true;
}
CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId)
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
const CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId) const
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
void GameState::save(nlohmann::json& out) const
{
out["schemaVersion"] = kSchemaVersion;
// Timestamp
std::time_t now = std::time(nullptr);
std::tm* tm = std::localtime(&now);
@@ -37,7 +46,7 @@ void GameState::save(nlohmann::json& out) const
// --- World ---
out["campaignId"] = campaignId;
out["currentLocationName"] = currentLocationName;
out["currentLocationId"] = currentLocationName;
out["isDarklands"] = isDarklands;
out["isNight"] = isNight;
out["isDawn"] = isDawn;
@@ -87,16 +96,21 @@ void GameState::save(nlohmann::json& out) const
questJournal.save(questJson);
out["questJournal"] = std::move(questJson);
// --- Players ---
out["players"] = nlohmann::json::object();
for (const auto& [id, player] : players) {
if (!player) continue;
nlohmann::json playerJson;
player->save(playerJson);
out["players"][id] = std::move(playerJson);
}
// --- Locations ---
// Unloaded locations keep their last stored state; loaded ones are saved live.
nlohmann::json locsJson = nlohmann::json::object();
for (const auto& [name, stored] : storedLocationStates) locsJson[name] = stored;
for (const auto& [name, loc] : locations) {
if (loc) {
nlohmann::json locJson;
loc->save(locJson);
locsJson[name] = std::move(locJson);
}
for (const auto& [name, location] : locations) {
nlohmann::json locJson;
location.save(locJson);
locsJson[name] = std::move(locJson);
}
out["locations"] = std::move(locsJson);
@@ -105,12 +119,16 @@ void GameState::save(nlohmann::json& out) const
void GameState::load(const nlohmann::json& in)
{
const int schemaVersion = in.value("schemaVersion", -1);
if (schemaVersion != kSchemaVersion)
throw std::runtime_error(
"Unsupported game-state schema version: " +
std::to_string(schemaVersion));
//logger() << "GameState::load step 1" << std::endl;
// --- World ---
// Literal default: saves made before campaigns existed belong to the main game.
campaignId = in.value("campaignId", std::string("main"));
currentLocationName = in.value("currentLocationName", currentLocationName);
campaignId = in.at("campaignId").get<std::string>();
currentLocationName = in.at("currentLocationId").get<std::string>();
isDarklands = in.value("isDarklands", isDarklands);
isNight = in.value("isNight", isNight);
isDawn = in.value("isDawn", isDawn);
@@ -142,11 +160,8 @@ void GameState::load(const nlohmann::json& in)
//logger() << "GameState::load step 5" << std::endl;
// --- Tutorial ---
if (in.contains("uniIntTutorialState")) {
tutorialStep = migrateLegacyTutorialStep(in.value("tutorialStep", 0), in["uniIntTutorialState"].get<int>());
} else {
tutorialStep = static_cast<TutorialStep>(in.value("tutorialStep", static_cast<int>(tutorialStep)));
}
tutorialStep = static_cast<TutorialStep>(
in.at("tutorialStep").get<int>());
tutorialPhoneChatScreenOpened = in.value("tutorialPhoneChatScreenOpened", tutorialPhoneChatScreenOpened);
tutorialJournalScreenOpened = in.value("tutorialJournalScreenOpened", tutorialJournalScreenOpened);
tutorialNeedOpenTaxiScreen = in.value("tutorialNeedOpenTaxiScreen", tutorialNeedOpenTaxiScreen);
@@ -193,20 +208,35 @@ void GameState::load(const nlohmann::json& in)
logger() << "GameState::load step 10" << std::endl;
// --- Locations ---
// Every location's state is stored (applied when the location loads); the ones
// already loaded get it right away.
storedLocationStates.clear();
if (in.contains("locations") && in["locations"].is_object()) {
for (const auto& [name, locJson] : in["locations"].items()) {
//logger() << "GameState::load step 10.1: " << name << std::endl;
storedLocationStates[name] = locJson;
// --- Players ---
const auto& playerValues = in.at("players");
if (!playerValues.is_object())
throw std::runtime_error("Game state players must be an object");
for (const auto& [id, value] : playerValues.items()) {
auto& player = players[id];
if (!player) player = std::make_shared<CharacterState>();
player->load(value);
player->configureAsPlayer();
}
for (auto it = players.begin(); it != players.end();) {
if (!playerValues.contains(it->first)) it = players.erase(it);
else ++it;
}
auto it = locations.find(name);
if (it != locations.end() && it->second) {
it->second->load(locJson);
}
}
// --- Locations ---
const auto& locationValues = in.at("locations");
if (!locationValues.is_object())
throw std::runtime_error("Game state locations must be an object");
if (!currentLocationName.empty() &&
!locationValues.contains(currentLocationName))
throw std::runtime_error(
"Game state does not contain current location '" +
currentLocationName + "'");
for (const auto& [id, value] : locationValues.items())
locations[id].load(value);
for (auto it = locations.begin(); it != locations.end();) {
if (!locationValues.contains(it->first)) it = locations.erase(it);
else ++it;
}
//logger() << "GameState::load step 11" << std::endl;
+13 -8
View File
@@ -1,7 +1,7 @@
#pragma once
#include "items/Item.h"
#include "quest/QuestJournal.h"
#include "Location.h"
#include "LocationGameState.h"
#include "ISaveable.h"
#include "external/nlohmann/json.hpp"
#include <unordered_map>
@@ -32,17 +32,17 @@ struct StoredChatMessage {
bool incoming;
};
using LocationMap = std::unordered_map<std::string, std::shared_ptr<Location>>;
struct GameState : public ISaveable {
static constexpr int kSchemaVersion = 2;
// --- World ---
// Campaign being played (see CampaignRegistry).
std::string campaignId = "main";
// The campaign's loaded location(s); only the one the player is in is kept loaded.
LocationMap locations;
// Saved state (Location::save) of the campaign's locations that are not loaded,
// applied when one is loaded again. save()/load() cover both maps.
std::unordered_map<std::string, nlohmann::json> storedLocationStates;
// Pure authoritative location data. Live Location/render/script instances are
// private runtime storage in InternalGameServer and never enter GameState.
std::unordered_map<std::string, LocationGameState> locations;
// Players are campaign-level entities and remain stable across locations.
PlayerStateMap players;
std::string currentLocationName;
bool isDarklands = false;
bool isNight = false;
@@ -73,6 +73,11 @@ struct GameState : public ISaveable {
// Moves to the next step if `step` is the current one; returns whether it did.
bool completeTutorialStep(TutorialStep step);
CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId);
const CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId) const;
// --- Phone / chat ---
bool chatUnread[3] = { true, true, true };
std::string chatPreviewMsg[3];
+695 -852
View File
File diff suppressed because it is too large. Load diff
+69 -48
View File
@@ -1,4 +1,4 @@
#pragma once
#pragma once
#include "render/Renderer.h"
#include "Environment.h"
@@ -16,13 +16,15 @@
#include "TeleportZone.h"
#include "LocationEditor.h"
#include "LocationState.h"
#include "ISaveable.h"
#include "LocationGameState.h"
#include <functional>
#include <cstdint>
#include <unordered_map>
#include "Character.h"
namespace FRG
{
class IGameServer;
struct PointLight
{
@@ -35,7 +37,7 @@ namespace FRG
// Rectangular limits — keep light confined to a room-sized AABB centred on the light
float limitX = 99999.f; // shader fragment limit: half-width in world X
float limitY = 99999.f; // shader fragment limit: half-width in world Z
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use legacy nearest-light logic
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use nearest-light logic
float autoLightLimitY = -1.0f; // activation rect half-width Z
};
@@ -46,16 +48,17 @@ namespace FRG
float radius = 1.5f;
float hysteresis = 0.3f; // exit fires at radius + hysteresis to prevent flickering
bool enabled = true;
bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationState)
bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationNetworkState)
};
struct LocationSetup
{
std::shared_ptr<const LocationDefinition> definition;
std::string gameObjectsJsonPath;
std::string interactiveObjectsJsonPath;
std::string npcsJsonPath;
std::string dialoguesJsonPath;
std::vector<std::string> navigationJsonPaths;
//std::vector<std::string> navigationJsonPaths;
std::string scriptPath;
std::string teleportsJsonPath;
std::string triggerZonesJsonPath;
@@ -64,17 +67,28 @@ namespace FRG
// Player shared by all locations of a campaign (see Location::createPlayer);
// setup() creates a private one when this is null.
std::shared_ptr<Character> player;
LocationGameState* gameState = nullptr;
std::shared_ptr<LocationLocalState> localState;
};
class Location : public ISaveable
class Location
{
public:
Location(Renderer& iRenderer, Inventory& iInventory);
Location(Renderer& iRenderer, IGameServer& server,
std::string locationId);
// Detaches the (shared, longer-lived) player from this location.
~Location();
// ---- All serialisable runtime state ----
LocationState state;
const std::string& locationId() const { return locationId_; }
std::shared_ptr<const LocationNetworkState> stateSnapshot() const;
LocationLocalState& localState() { return *localState_; }
const LocationLocalState& localState() const { return *localState_; }
bool isDarklands() const;
bool isNight() const;
bool isDawn() const;
int globalInt(const std::string& key) const;
float globalFloat(const std::string& key) const;
void setGlobalInt(const std::string& key, int value);
void setGlobalFloat(const std::string& key, float value);
std::unordered_map<std::string, LoadedGameObject> gameObjects;
@@ -88,10 +102,8 @@ namespace FRG
std::vector<std::unique_ptr<Character>> npcs;
// Builds the player character (models, animations, weapon, textures).
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer);
std::vector<PathFinder> navigationMaps;
PathFinder* navigation = nullptr;
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer,
IGameServer& server, const std::string& playerId);
std::shared_ptr<ShadowMap> shadowMap;
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
@@ -114,10 +126,6 @@ namespace FRG
std::function<void()> onPlayerTaxiRequired;
// Optional, set by Game. While it returns true the player can't pick up /
// use interactive objects (the dorm tutorial unlocks them step by step).
std::function<bool()> areInteractiveObjectsLocked;
// Set by Game after setup(). Lua and onDeathAnimComplete call this to
// request the transition without coupling Location to Game directly.
std::function<bool()> requestDarklandsTransition;
@@ -140,7 +148,7 @@ namespace FRG
EditorMode editorMode = EditorMode::None;
LocationEditor editor;
std::vector<std::string> navigationMapPaths;
//std::vector<std::string> navigationMapPaths;
// Set by Game when the user's primary pointer (left mouse / single touch)
// has crossed the tap-vs-drag threshold and is now rotating the camera.
@@ -161,9 +169,33 @@ namespace FRG
void setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer);
void setupNavigation(const std::vector<std::string>& paths);
void setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer);
//void setupNavigation(const std::vector<std::string>& paths);
bool switchNavigation(int index);
void setCharacterTarget(Character& character, const Eigen::Vector3f& target,
std::string callbackName = "");
void updateCharacterState(
Character& character,
const std::function<void(CharacterState&)>& mutation);
void clearCharacterAttackTarget(Character& character);
void stopCharacter(Character& character);
void faceCharacter(Character& character, std::string targetId);
void replanCharacter(Character& character);
void resetCharacterAfterDeath(Character& character);
void tryCharacterAttackCombo(Character& character);
void setCharacterHp(Character& character, float hp);
bool hasInventoryItem(const std::string& itemId) const;
std::size_t inventoryItemCount() const;
void addInventoryItem(std::string itemId);
void removeInventoryItem(std::string itemId);
bool unlockQuest(const std::string& questId);
bool completeQuest(const std::string& questId);
bool failQuest(const std::string& questId);
bool setQuestObjectiveCompleted(
const std::string& questId, const std::string& objectiveId, bool completed);
bool setQuestObjectiveVisible(
const std::string& questId, const std::string& objectiveId, bool visible);
bool setQuestActiveObjective(const std::string& questId, int objectiveIndex);
InteractiveObject* raycastInteractiveObjects(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir);
Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f);
@@ -177,8 +209,7 @@ namespace FRG
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
void update(int64_t deltaMs);
void updateNpcCar(int64_t deltaMs);
void update(int64_t deltaMs, const LocationSimulationEvents& events);
// Purely visual feedback for click-triggered player routes. Disabling it
// also cancels an animation that is currently playing.
void setPathVisualizationEnabled(bool enabled);
@@ -194,26 +225,26 @@ namespace FRG
void setDialogueFlag(const std::string& flag, int value);
int getDialogueFlag(const std::string& flag) const;
void onTaxiCalled();
// ---- Save / Load ----
// Serialises LocationState + all Character/InteractiveObject sub-states + Lua globals.
void save(nlohmann::json& out) const override;
// Restores state from JSON written by save.
// Navigation pointer and triggerZone.playerInside are re-synced automatically.
void load(const nlohmann::json& in) override;
protected:
friend class LocationEditor;
friend class Game;
friend class ScriptEngine;
friend class LocalGameServerHost;
friend class RemoteGameServerHost;
Renderer& renderer;
Inventory& inventory;
AudioPlayerAsync* audioPlayer;
private:
IGameServer& gameServer_;
std::string locationId_;
std::shared_ptr<LocationLocalState> localState_;
std::unordered_map<std::string, std::shared_ptr<Character>> remotePlayers_;
void synchronizeRemotePlayers();
void drawRemotePlayerNames(
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix);
// ---- Pointer-to-index helpers ----
InteractiveObject* getTargetInteractiveObject();
TeleportZone* getTargetTeleportZone();
int findInteractiveObjectIndex(const InteractiveObject* obj) const;
int findTeleportZoneIndex(const TeleportZone* tz) const;
TeleportZone* getTargetTeleportZone(const LocationNetworkState& state);
enum class InstancedTexture { None, Color, Darklands };
@@ -223,26 +254,16 @@ namespace FRG
void drawInstancedStatics(const std::string& instancedShaderName, InstancedTexture textureKind,
const std::function<void()>& setUniforms = {});
void resolveCharacterCollisions();
void updateDynamicReplans(int64_t deltaMs);
void loadTeleportZones(const std::string& jsonPath, const char* zipFile);
void loadTriggerZones(const std::string& jsonPath, const char* zipFile);
void loadPointLights(const std::string& jsonPath, const char* zipFile);
void updateTriggerZones(const Eigen::Vector3f& playerPos);
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
int findNpcIndex(const Character* npc) const;
void fireBumpCallbacks(Character* mover, Character* standing);
void captureAuxiliaryState(LocationGameState& out) const;
void applyAuxiliaryState(const LocationGameState& in);
PathVisualization pathVisualization;
std::unordered_map<Character*, Eigen::Vector3f> lastCharacterPositions;
std::unordered_map<Character*, int64_t> replanCooldownRemainingMs;
std::unordered_map<Character*, int> stuckFrameCounts;
std::unordered_map<Character*, float> stuckPauseRemainingS;
static constexpr float NPC_BUMP_CALLBACK_COOLDOWN = 5.0f;
std::unordered_map<int, float> npcBumpedByPlayerCooldown;
std::unordered_map<int, float> npcBumpsPlayerCooldown;
};
} // namespace FRG
+45 -15
View File
@@ -1,6 +1,7 @@
#include "LocationEditor.h"
#include "Location.h"
#include "Character.h"
#include "game_server/IGameServer.h"
#include "utils/Utils.h"
#include "render/OpenGlExtensions.h"
#include "TextModel.h"
@@ -24,10 +25,14 @@ namespace FRG
void LocationEditor::buildNavMeshes()
{
navigationEditorNavMeshes.clear();
if (!loc.navigation) return;
const float y = loc.navigation->getFloorY() + 0.02f;
auto state = loc.stateSnapshot();
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const PathFinder& navigation =
state->navigationMaps[state->activeNavigationIndex];
const float y = navigation.getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
for (const auto& obs : loc.navigation->getObstaclePolygons()) {
for (const auto& obs : navigation.getObstaclePolygons()) {
if (obs.polygon.size() < 3) continue;
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
@@ -51,7 +56,11 @@ namespace FRG
{
VertexDataStruct data;
const Eigen::Vector3f yellow(1.0f, 1.0f, 0.0f);
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.05f : 0.05f;
auto state = loc.stateSnapshot();
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const float y =
state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.05f;
const float s = 0.2f;
for (const auto& pt : navigationEditorPoints) {
@@ -68,12 +77,19 @@ namespace FRG
}
navigationEditorPointsMesh.data = std::move(data);
navigationEditorPointsMesh.RefreshVBO();
if (navigationEditorPointsMesh.data.PositionData.size() > 0)
{
navigationEditorPointsMesh.RefreshVBO();
}
}
void LocationEditor::drawPoints()
{
if (navigationEditorPoints.empty()) return;
if (navigationEditorPointsMesh.data.PositionData.empty())
{
return;
}
loc.renderer.shaderManager.PushShader("defaultColor");
loc.renderer.SetMatrix();
loc.renderer.DrawVertexRenderStruct(navigationEditorPointsMesh);
@@ -120,12 +136,17 @@ namespace FRG
poly.polygon.emplace_back(pt.x(), pt.z());
}
if (loc.navigation) loc.navigation->addObstaclePolygon(poly);
loc.gameServer_.submit(AddNavigationObstacleCommand{
loc.locationId_, poly.name, poly.polygon});
logger() << "[NAV_EDITOR] Added obstacle '" << poly.name << "' with "
<< poly.polygon.size() << " vertices\n";
{
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.02f : 0.02f;
auto state = loc.stateSnapshot();
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const float y =
state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
@@ -147,14 +168,21 @@ namespace FRG
if (!std::filesystem::exists(baseName + ".json")) break;
}
if (!loc.navigation) return;
auto state = loc.stateSnapshot();
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) {
std::cerr << "[NAV_EDITOR] No active navigation map to save\n";
return;
}
const PathFinder& navigation =
state->navigationMaps[state->activeNavigationIndex];
if (loc.navigation->saveConfig(baseName + ".json"))
if (navigation.saveConfig(baseName + ".json"))
logger() << "[NAV_EDITOR] Saved config to: " << baseName << ".json\n";
else
std::cerr << "[NAV_EDITOR] Failed to save config to: " << baseName << ".json\n";
if (loc.navigation->saveGrid(baseName + ".txt"))
if (navigation.saveGrid(baseName + ".txt"))
logger() << "[NAV_EDITOR] Saved grid to: " << baseName << ".txt\n";
else
std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n";
@@ -162,8 +190,8 @@ namespace FRG
void LocationEditor::reload()
{
loc.setupNavigation(loc.navigationMapPaths);
logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
//loc.setupNavigation(loc.navigationMapPaths);
//logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
}
static VertexDataStruct CreateBoundsBoxMesh(const Eigen::Vector3f corners[8], const Eigen::Vector3f& color)
@@ -454,9 +482,11 @@ namespace FRG
data.rotationX = 0.0f;
data.rotationY = rotDist(rng);
data.rotationZ = 0.0f;
data.positionX = loc.player->state.position.x();
data.positionY = loc.player->state.position.y();//-0.1;
data.positionZ = loc.player->state.position.z();
auto playerState = loc.player ? loc.player->stateSnapshot() : nullptr;
if (!playerState) return;
data.positionX = playerState->position.x();
data.positionY = playerState->position.y();//-0.1;
data.positionZ = playerState->position.z();
data.scale = scaleDist(rng);
GameObjectData data2;
+445
View File
@@ -0,0 +1,445 @@
#include "LocationGameState.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace FRG {
namespace {
constexpr float PI = 3.14159265358979323846f;
constexpr float NPC_TALK_DISTANCE = 1.35f;
}
bool LocationGameState::setup(
LocationDefinition content, const std::string& zipFile)
{
const bool initializeRuntimeFields = !hasPersistentState;
if (!location) location = std::make_shared<LocationNetworkState>();
if (location->navigationMaps.empty())
location->initNavigation(content.config.navigationJsonPaths, zipFile);
lockInteractiveObjectsDuringTutorial =
content.config.lockInteractiveObjectsDuringTutorial;
if (content.config.taxi) {
const LocationConfig::Taxi& taxi = *content.config.taxi;
location->taxiCarTriggerPosition = taxi.triggerPosition;
location->taxiCarWaypoints = taxi.waypoints;
location->taxiCarInitialState.position = taxi.initialPosition;
location->taxiCarInitialState.rotation = taxi.initialRotation;
location->taxiCarInitialState.mode = NpcCar::Mode::NONE;
location->taxiCarInitialState.velocity = 0.f;
location->taxiCarInitialState.steeringAngle = 0.f;
location->taxiCarInitialState.waypointReachRadius =
taxi.waypointReachRadius;
location->taxiCar.waypointReachRadius = taxi.waypointReachRadius;
if (initializeRuntimeFields) resetTaxi();
}
std::unordered_map<std::string, bool> configuredNpcs;
for (const NpcData& data : content.npcs) {
configuredNpcs.emplace(data.id, true);
CharacterState defaults = data.toCharacterState();
loadCharacterAnimationMetadata(defaults, zipFile);
auto& state = npcs[data.id];
if (!state) {
state = std::make_shared<CharacterState>(std::move(defaults));
} else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(defaults);
state->load(runtime);
}
}
for (const auto& [id, state] : npcs)
if (configuredNpcs.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown NPC '" + id + "'");
std::unordered_map<std::string, bool> configuredObjects;
for (const InteractiveObjectData& data : content.interactiveObjects) {
configuredObjects.emplace(data.id, true);
InteractiveObjectState defaults = data.toInteractiveObjectState();
auto& state = interactiveObjects[data.id];
if (!state) {
state =
std::make_shared<InteractiveObjectState>(std::move(defaults));
} else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(defaults);
state->load(runtime);
}
}
for (const auto& [id, state] : interactiveObjects)
if (configuredObjects.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown interactive object '" +
id + "'");
std::unordered_map<std::string, TriggerZoneGameState> newTriggers;
for (const TriggerZoneDefinition& data : content.triggerZones) {
auto existing = triggerZones.find(data.id);
newTriggers[data.id] = existing != triggerZones.end()
? existing->second
: TriggerZoneGameState{data.enabled, false};
}
if (hasPersistentState && newTriggers.size() != triggerZones.size())
throw std::runtime_error(
"Saved trigger-zone IDs do not match location content");
triggerZones = std::move(newTriggers);
std::unordered_map<std::string, bool> newTeleports;
for (const TeleportDefinition& data : content.teleports) {
auto existing = teleportZones.find(data.id);
newTeleports[data.id] =
existing != teleportZones.end() ? existing->second : data.active;
}
if (hasPersistentState && newTeleports.size() != teleportZones.size())
throw std::runtime_error(
"Saved teleport IDs do not match location content");
teleportZones = std::move(newTeleports);
definition =
std::make_shared<const LocationDefinition>(std::move(content));
return initializeRuntimeFields;
}
CharacterState* LocationGameState::findCharacter(
const std::string& id, PlayerStateMap& players)
{
if (id.empty()) return nullptr;
auto entity = players.find(id);
if (entity != players.end() && entity->second) return entity->second.get();
auto npc = npcs.find(id);
if (npc != npcs.end() && npc->second) return npc->second.get();
return nullptr;
}
const CharacterState* LocationGameState::findCharacter(
const std::string& id, const PlayerStateMap& players) const
{
if (id.empty()) return nullptr;
auto entity = players.find(id);
if (entity != players.end() && entity->second) return entity->second.get();
auto npc = npcs.find(id);
if (npc != npcs.end() && npc->second) return npc->second.get();
return nullptr;
}
void LocationGameState::startTaxi()
{
location->taxiCar.waypoints = location->taxiCarWaypoints;
location->taxiCar.currentWaypoint = 0;
location->taxiCar.mode = NpcCar::Mode::FOLLOW_WAYPOINTS;
}
void LocationGameState::resetTaxi()
{
location->taxiCar = location->taxiCarInitialState;
location->taxiArrivedCallbackCalled = false;
}
bool LocationGameState::updateNpcCar(std::int64_t deltaMs)
{
const float dt = static_cast<float>(deltaMs) / 1000.0f;
NpcCar& car = location->taxiCar;
Eigen::Vector3f target = car.position;
float throttle = 0.f;
bool hasTarget = false;
bool waypointsOver = false;
if (car.mode == NpcCar::Mode::FOLLOW_WAYPOINTS) {
if (!car.waypoints.empty()) {
if (car.currentWaypoint >= car.waypoints.size())
car.currentWaypoint = 0;
Eigen::Vector3f toTarget =
car.waypoints[car.currentWaypoint] - car.position;
toTarget.y() = 0.f;
if (toTarget.norm() < car.waypointReachRadius) {
++car.currentWaypoint;
if (car.currentWaypoint == car.waypoints.size())
waypointsOver = true;
}
if (waypointsOver) {
car.mode = NpcCar::Mode::NONE;
} else {
target = car.waypoints[car.currentWaypoint];
throttle = 1.f;
hasTarget = true;
}
}
}
float targetHeading = car.rotation;
if (hasTarget) {
Eigen::Vector3f toTarget = target - car.position;
toTarget.y() = 0.f;
if (toTarget.squaredNorm() > 1e-4f)
targetHeading = std::atan2(-toTarget.x(), -toTarget.z());
}
float angleDiff = targetHeading - car.rotation;
while (angleDiff > PI) angleDiff -= 2.f * PI;
while (angleDiff < -PI) angleDiff += 2.f * PI;
const float turnIntent = (std::max)(-1.f, (std::min)(1.f, angleDiff * 2.f));
const float targetSteer = turnIntent * NpcCar::maxSteerAngle;
const float steerLerp = (std::min)(1.f, dt * 8.f);
car.steeringAngle += (targetSteer - car.steeringAngle) * steerLerp;
if (throttle > 0.f) {
car.velocity += NpcCar::acceleration * dt * throttle;
} else {
const float resistance = NpcCar::friction * dt;
if (car.velocity > 0.f)
car.velocity = (std::max)(0.f, car.velocity - resistance);
else if (car.velocity < 0.f)
car.velocity = (std::min)(0.f, car.velocity + resistance);
}
car.velocity = (std::max)(
-NpcCar::maxReverseSpeed, (std::min)(NpcCar::maxSpeed, car.velocity));
if (std::abs(car.velocity) > 0.01f) {
const float speedFactor = car.velocity / NpcCar::maxSpeed;
car.rotation += turnIntent * NpcCar::turnRate * dt * speedFactor;
}
const Eigen::Vector3f forward(
-std::sin(car.rotation), 0.f, -std::cos(car.rotation));
const Eigen::Vector3f totalDelta = forward * car.velocity * dt;
const float totalDist = totalDelta.norm();
constexpr float maxSubstep = 0.2f;
const int substeps = (std::max)(
1, static_cast<int>(std::ceil(totalDist / maxSubstep)));
const Eigen::Vector3f stepDelta = totalDelta / static_cast<float>(substeps);
for (int step = 0; step < substeps; ++step)
car.position += stepDelta;
car.wheelAngle += car.velocity * 4.f * dt;
while (car.wheelAngle > 2.f * PI)
car.wheelAngle -= 2.f * PI;
if (!location->taxiArrivedCallbackCalled) {
const Eigen::Vector3f toArrival =
car.position - location->taxiCarTriggerPosition;
if (toArrival.squaredNorm() < 9.f) {
location->taxiArrivedCallbackCalled = true;
return true;
}
}
return false;
}
LocationSimulationEvents LocationGameState::update(
std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked)
{
LocationSimulationEvents result;
result.taxiArrived = updateNpcCar(deltaMs);
std::vector<std::string> objectIds;
objectIds.reserve(interactiveObjects.size());
for (const auto& [id, object] : interactiveObjects)
if (object) objectIds.push_back(id);
std::sort(objectIds.begin(), objectIds.end());
for (const std::string& id : objectIds) {
InteractiveObjectUpdateEvents events =
interactiveObjects.at(id)->update(deltaMs);
if (events.onCompleteName.empty()) continue;
result.interactiveObjects.push_back(
{id, std::move(events.onCompleteName), false, {}});
}
// All characters observe the same tick-start state. Damage is applied only
// after every state has advanced, so vector order cannot suppress an attack.
std::unordered_map<std::string, CharacterObservation> observations;
for (const auto& [id, player] : players)
if (player)
observations.emplace(
id, CharacterObservation{player->position, player->hp});
for (const auto& [id, npc] : npcs)
if (npc)
observations.emplace(
id, CharacterObservation{npc->position, npc->hp});
auto advance = [&](const std::string& id, CharacterState& state) {
CharacterUpdateContext context;
auto attack = observations.find(state.attackTargetId);
if (attack != observations.end()) context.attackTarget = &attack->second;
auto face = observations.find(state.faceTargetId);
if (face != observations.end()) context.faceTarget = &face->second;
CharacterUpdateEvents events = state.update(deltaMs, context);
if (!events.arrivedCallbackName.empty() || events.attackImpact ||
events.deathAnimationComplete || events.navigationRequested) {
result.characters.push_back({id, std::move(events)});
}
};
std::vector<std::string> playerIds;
playerIds.reserve(players.size());
for (const auto& [id, player] : players)
if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
for (const std::string& id : playerIds)
advance(id, *players.at(id));
std::vector<std::string> npcIds;
npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs)
if (npc) npcIds.push_back(id);
std::sort(npcIds.begin(), npcIds.end());
for (const std::string& id : npcIds)
advance(id, *npcs.at(id));
for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue;
CharacterState* target = findCharacter(source.events.attackTargetId, players);
if (!target) continue;
constexpr float damage = 10.f;
target->setHp(target->hp - damage);
result.damage.push_back({
source.events.attackTargetId,
source.characterId,
damage,
target->hp,
target->initialHp,
source.events.attackDirection});
}
auto player = players.find("player");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second) {
auto target = interactiveObjects.find(location->targetInteractiveObjectId);
const auto object =
target == interactiveObjects.end() ? nullptr : target->second;
if (object && !object->isAnimating) {
const Eigen::Vector3f& approachTarget = object->hasInteractionPosition
? object->interactionPosition : object->position;
if ((player->second->position - approachTarget).norm() <= object->approachRadius) {
result.interactiveObjects.push_back(
{location->targetInteractiveObjectId, {}, true,
object->activateFunctionName});
location->targetInteractiveObjectId.clear();
}
}
}
if (!location->targetInteractNpcId.empty() &&
player != players.end() && player->second &&
npcs.count(location->targetInteractNpcId) != 0 &&
npcs.at(location->targetInteractNpcId)) {
const auto& npc = npcs.at(location->targetInteractNpcId);
const float distance =
(player->second->position - npc->position).norm();
if (distance <= NPC_TALK_DISTANCE) {
player->second->stopInPlace();
npc->faceTargetId = "player";
result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear();
}
}
return result;
}
void LocationGameState::save(nlohmann::json& out) const
{
location->save(out["state"]);
out["npcs"] = nlohmann::json::object();
for (const auto& [id, npc] : npcs) {
if (npc) {
nlohmann::json value;
npc->save(value);
out["npcs"][id] = std::move(value);
}
}
out["interactiveObjects"] = nlohmann::json::object();
for (const auto& [id, object] : interactiveObjects) {
if (!object) continue;
nlohmann::json value;
object->save(value);
out["interactiveObjects"][id] = std::move(value);
}
out["dialogue"] = dialogue;
out["scriptGlobals"] = scriptGlobals;
out["triggerZones"] = nlohmann::json::object();
for (const auto& [id, zone] : triggerZones)
out["triggerZones"][id] = {{"enabled", zone.enabled}, {"playerInside", zone.playerInside}};
out["teleportZones"] = nlohmann::json::object();
for (const auto& [id, active] : teleportZones)
out["teleportZones"][id] = active;
}
void LocationGameState::load(const nlohmann::json& in)
{
hasPersistentState = true;
if (!location) location = std::make_shared<LocationNetworkState>();
location->load(in.at("state"));
const auto& npcValues = in.at("npcs");
if (!npcValues.is_object())
throw std::runtime_error("Location npcs must be an object");
{
const auto& values = npcValues;
if (!npcs.empty()) {
for (const auto& [id, npc] : npcs)
if (!values.contains(id))
throw std::runtime_error(
"Saved location is missing NPC '" + id + "'");
for (const auto& [id, value] : values.items())
if (npcs.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown NPC '" + id + "'");
}
for (const auto& [id, value] : values.items()) {
auto& npc = npcs[id];
if (!npc) npc = std::make_shared<CharacterState>();
npc->load(value);
}
}
const auto& objectValues = in.at("interactiveObjects");
if (!objectValues.is_object())
throw std::runtime_error("Location interactiveObjects must be an object");
{
const auto& values = objectValues;
if (!interactiveObjects.empty()) {
for (const auto& [id, object] : interactiveObjects)
if (!values.contains(id))
throw std::runtime_error(
"Saved location is missing interactive object '" + id + "'");
for (const auto& [id, value] : values.items())
if (interactiveObjects.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown interactive object '" + id + "'");
}
for (const auto& [id, value] : values.items()) {
auto& object = interactiveObjects[id];
if (!object) object = std::make_shared<InteractiveObjectState>();
object->load(value);
}
}
dialogue = in.at("dialogue");
scriptGlobals = in.at("scriptGlobals");
triggerZones.clear();
const auto& triggerValues = in.at("triggerZones");
if (!triggerValues.is_object())
throw std::runtime_error("Location triggerZones must be an object");
for (const auto& [id, value] : triggerValues.items())
triggerZones[id] = {
value.at("enabled").get<bool>(),
value.at("playerInside").get<bool>()};
teleportZones.clear();
const auto& teleportValues = in.at("teleportZones");
if (!teleportValues.is_object())
throw std::runtime_error("Location teleportZones must be an object");
for (const auto& [id, value] : teleportValues.items())
teleportZones[id] = value.get<bool>();
}
} // namespace FRG
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include "CharacterState.h"
#include "LocationState.h"
#include "items/InteractiveObjectState.h"
#include "content/LocationDefinition.h"
#include "external/nlohmann/json.hpp"
#include <string>
#include <unordered_map>
#include <vector>
namespace FRG {
struct TriggerZoneGameState {
bool enabled = true;
bool playerInside = false;
};
struct CharacterSimulationEvent {
std::string characterId;
CharacterUpdateEvents events;
};
struct CharacterDamageEvent {
std::string targetId;
std::string attackerId;
float amount = 0.f;
float hp = 0.f;
float maxHp = 0.f;
Eigen::Vector3f direction = Eigen::Vector3f::Zero();
};
struct InteractiveObjectSimulationEvent {
std::string objectId;
std::string animationCompleteFunctionName;
bool activated = false;
std::string activateFunctionName;
};
struct LocationSimulationEvents {
std::vector<CharacterSimulationEvent> characters;
std::vector<CharacterDamageEvent> damage;
std::vector<InteractiveObjectSimulationEvent> interactiveObjects;
std::string npcInteractionId;
bool taxiArrived = false;
};
// Pure authoritative state for one location. Shared ownership gives bound local
// simulation objects stable addresses; runtime renderers and Location pointers
// are deliberately absent, and public clients receive value-only snapshots.
struct LocationGameState {
std::shared_ptr<LocationNetworkState> location = std::make_shared<LocationNetworkState>();
std::unordered_map<std::string, std::shared_ptr<CharacterState>> npcs;
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>> interactiveObjects;
bool lockInteractiveObjectsDuringTutorial = false;
bool hasPersistentState = false;
std::unordered_map<std::string, TriggerZoneGameState> triggerZones;
std::unordered_map<std::string, bool> teleportZones;
nlohmann::json dialogue = nlohmann::json::object();
nlohmann::json scriptGlobals = nlohmann::json::object();
std::shared_ptr<const LocationDefinition> definition;
bool setup(LocationDefinition content, const std::string& zipFile);
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const;
void startTaxi();
void resetTaxi();
LocationSimulationEvents update(
std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked);
void save(nlohmann::json& out) const;
void load(const nlohmann::json& in);
private:
bool updateNpcCar(std::int64_t deltaMs);
};
} // namespace FRG
+161 -218
View File
@@ -1,234 +1,177 @@
#include "LocationState.h"
#include <stdexcept>
namespace FRG {
void LocationState::save(nlohmann::json& out) const
std::shared_ptr<LocationLocalState> ClientLocationState::getOrCreate(
const std::string& locationId)
{
out["cameraAzimuth"] = cameraAzimuth;
out["cameraInclination"] = cameraInclination;
out["activeNavigationIndex"] = activeNavigationIndex;
out["targetInteractiveObjectIndex"] = targetInteractiveObjectIndex;
out["targetInteractNpcIndex"] = targetInteractNpcIndex;
out["targetTeleportZoneIndex"] = targetTeleportZoneIndex;
out["isDarklands"] = isDarklands;
out["isNight"] = isNight;
out["isDawn"] = isDawn;
// Taxi stuff:
nlohmann::json taxiJson;
taxiJson["taxiCar"] = {
{"positionX", taxiCar.position.x()},
{"positionY", taxiCar.position.y()},
{"positionZ", taxiCar.position.z()},
{"rotation", taxiCar.rotation},
{"velocity", taxiCar.velocity},
{"steeringAngle", taxiCar.steeringAngle},
{"mode", static_cast<int>(taxiCar.mode)},
{"waypoints", nlohmann::json::array()},
{"currentWaypoint", taxiCar.currentWaypoint},
{"waypointReachRadius", taxiCar.waypointReachRadius}
};
nlohmann::json taxiWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCar.waypoints.size(); ++i) {
taxiWaypoints.push_back({
{"x", taxiCar.waypoints[i].x()},
{"y", taxiCar.waypoints[i].y()},
{"z", taxiCar.waypoints[i].z()}
});
}
taxiJson["taxiCar"]["waypoints"] = std::move(taxiWaypoints);
taxiJson["taxiCarInitialState"] = {
{"positionX", taxiCarInitialState.position.x()},
{"positionY", taxiCarInitialState.position.y()},
{"positionZ", taxiCarInitialState.position.z()},
{"rotation", taxiCarInitialState.rotation},
{"velocity", taxiCarInitialState.velocity},
{"steeringAngle", taxiCarInitialState.steeringAngle},
{"mode", static_cast<int>(taxiCarInitialState.mode)},
{"waypoints", nlohmann::json::array()},
{"currentWaypoint", taxiCarInitialState.currentWaypoint},
{"waypointReachRadius", taxiCarInitialState.waypointReachRadius}
};
nlohmann::json taxiInitialWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCarInitialState.waypoints.size(); ++i) {
taxiInitialWaypoints.push_back({
{"x", taxiCarInitialState.waypoints[i].x()},
{"y", taxiCarInitialState.waypoints[i].y()},
{"z", taxiCarInitialState.waypoints[i].z()}
});
}
taxiJson["taxiCarInitialState"]["waypoints"] = std::move(taxiInitialWaypoints);
nlohmann::json taxiDefaultWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCarWaypoints.size(); ++i) {
taxiDefaultWaypoints.push_back({
{"x", taxiCarWaypoints[i].x()},
{"y", taxiCarWaypoints[i].y()},
{"z", taxiCarWaypoints[i].z()}
});
}
taxiJson["taxiDefaultWaypoints"] = taxiDefaultWaypoints;
/* taxiCarTriggerPosition = Eigen::Vector3f(
taxiJson.value("taxiCarTriggerPositionX", 0.f),
taxiJson.value("taxiCarTriggerPositionY", 0.f),
taxiJson.value("taxiCarTriggerPositionZ", 0.f)
);*/
taxiJson["taxiCarTriggerPositionX"] = taxiCarTriggerPosition.x();
taxiJson["taxiCarTriggerPositionY"] = taxiCarTriggerPosition.y();
taxiJson["taxiCarTriggerPositionZ"] = taxiCarTriggerPosition.z();
// {"y", taxiCarTriggerPosition.y()},
// {"z", taxiCarTriggerPosition.z()}
//};
taxiJson["taxiArrivedCallbackCalled"] = taxiArrivedCallbackCalled;
//questJournal.save(questJson);
out["taxi"] = std::move(taxiJson);
auto& state = locations[locationId];
if (!state) state = std::make_shared<LocationLocalState>();
return state;
}
void LocationState::load(const nlohmann::json& in)
void ClientLocationState::clear()
{
//logger() << "LocationState::load step 1" << std::endl;
locations.clear();
}
cameraAzimuth = in.value("cameraAzimuth", cameraAzimuth);
cameraInclination = in.value("cameraInclination", cameraInclination);
void LocationLocalState::save(nlohmann::json& out) const
{
out = {
{"cameraAzimuth", cameraAzimuth},
{"cameraInclination", cameraInclination}
};
}
void LocationLocalState::load(const nlohmann::json& in)
{
cameraAzimuth = in.at("cameraAzimuth").get<float>();
cameraInclination = in.at("cameraInclination").get<float>();
}
void ClientLocationState::save(nlohmann::json& out) const
{
out["locations"] = nlohmann::json::object();
for (const auto& [id, state] : locations) {
if (!state) continue;
nlohmann::json value;
state->save(value);
out["locations"][id] = std::move(value);
}
}
void ClientLocationState::load(const nlohmann::json& in)
{
const auto& values = in.at("locations");
if (!values.is_object())
throw std::runtime_error("Client location state locations must be an object");
for (auto& [id, state] : locations)
if (state) *state = LocationLocalState{};
for (const auto& [id, value] : values.items())
getOrCreate(id)->load(value);
}
void saveSinglePlayerPersistence(
const nlohmann::json& networkState,
const ClientLocationState& localState,
nlohmann::json& out)
{
nlohmann::json savedLocalState;
localState.save(savedLocalState);
out = {
{"persistenceVersion", kSinglePlayerPersistenceVersion},
{"networkState", networkState},
{"localState", std::move(savedLocalState)}
};
}
void loadSinglePlayerPersistence(
const nlohmann::json& in,
nlohmann::json& networkState,
ClientLocationState& localState)
{
const int version = in.value("persistenceVersion", -1);
if (version != kSinglePlayerPersistenceVersion)
throw std::runtime_error(
"Unsupported single-player persistence version: " +
std::to_string(version));
networkState = in.at("networkState");
localState.load(in.at("localState"));
}
namespace {
nlohmann::json saveCar(const NpcCar& car)
{
nlohmann::json waypoints = nlohmann::json::array();
for (const auto& waypoint : car.waypoints)
waypoints.push_back(
{{"x", waypoint.x()}, {"y", waypoint.y()}, {"z", waypoint.z()}});
return {
{"position", {car.position.x(), car.position.y(), car.position.z()}},
{"rotation", car.rotation},
{"velocity", car.velocity},
{"steeringAngle", car.steeringAngle},
{"wheelAngle", car.wheelAngle},
{"mode", static_cast<int>(car.mode)},
{"waypoints", std::move(waypoints)},
{"currentWaypoint", car.currentWaypoint}
};
}
void loadCar(NpcCar& car, const nlohmann::json& value)
{
const auto& position = value.at("position");
car.position = {
position.at(0).get<float>(),
position.at(1).get<float>(),
position.at(2).get<float>()};
car.rotation = value.at("rotation").get<float>();
car.velocity = value.at("velocity").get<float>();
car.steeringAngle = value.at("steeringAngle").get<float>();
car.wheelAngle = value.at("wheelAngle").get<float>();
car.mode = static_cast<NpcCar::Mode>(value.at("mode").get<int>());
car.waypoints.clear();
for (const auto& waypoint : value.at("waypoints"))
car.waypoints.push_back({
waypoint.at("x").get<float>(),
waypoint.at("y").get<float>(),
waypoint.at("z").get<float>()});
car.currentWaypoint = value.at("currentWaypoint").get<std::size_t>();
}
}
std::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const
{
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {});
}
void LocationNetworkState::initNavigation(
const std::vector<std::string>& paths, const std::string& zipFile)
{
const int savedActiveNavigationIndex = activeNavigationIndex;
//navigationMapPaths = paths;
navigationMaps.clear();
navigationMaps.resize(paths.size());
for (size_t i = 0; i < paths.size(); ++i) {
navigationMaps[i].build(paths[i], zipFile);
}
activeNavigationIndex =
savedActiveNavigationIndex >= 0 &&
savedActiveNavigationIndex < static_cast<int>(navigationMaps.size())
? savedActiveNavigationIndex
: 0;
}
void LocationNetworkState::save(nlohmann::json& out) const
{
out["activeNavigationIndex"] = activeNavigationIndex;
out["targetInteractiveObjectId"] = targetInteractiveObjectId;
out["targetInteractNpcId"] = targetInteractNpcId;
out["targetTeleportZoneId"] = targetTeleportZoneId;
out["taxi"] = {
{"car", saveCar(taxiCar)},
{"arrivedCallbackCalled", taxiArrivedCallbackCalled}
};
}
void LocationNetworkState::load(const nlohmann::json& in)
{
activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex);
targetInteractiveObjectIndex = in.value("targetInteractiveObjectIndex", -1);
targetInteractNpcIndex = in.value("targetInteractNpcIndex", -1);
targetTeleportZoneIndex = in.value("targetTeleportZoneIndex", -1);
isDarklands = in.value("isDarklands", false);
isNight = in.value("isNight", false);
isDawn = in.value("isDawn", false);
//logger() << "LocationState::load step 2" << std::endl;
nlohmann::json taxiJson = in["taxi"];
taxiArrivedCallbackCalled = taxiJson.value("taxiArrivedCallbackCalled", false);
//----- Taxi State ----
nlohmann::json taxiCarJson = taxiJson["taxiCar"];
taxiCar.position = Eigen::Vector3f(
taxiCarJson.value("positionX", 0.f),
taxiCarJson.value("positionY", 0.f),
taxiCarJson.value("positionZ", 0.f)
);
//logger() << "LocationState::load step 3" << std::endl;
taxiCar.rotation = taxiCarJson.value("rotation", 0.f);
taxiCar.velocity = taxiCarJson.value("velocity", 0.f);
taxiCar.steeringAngle = taxiCarJson.value("steeringAngle", 0.f);
taxiCar.mode = static_cast<NpcCar::Mode>(taxiCarJson.value("mode", 0));
//logger() << "LocationState::load step 4" << std::endl;
taxiCar.waypoints.clear();
if (taxiCarJson.contains("waypoints") && taxiCarJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarJson["waypoints"].size()); ++i)
taxiCar.waypoints.push_back(Eigen::Vector3f(
taxiCarJson["waypoints"][i].value("x", 0.f),
taxiCarJson["waypoints"][i].value("y", 0.f),
taxiCarJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 5" << std::endl;
taxiCar.currentWaypoint = taxiCarJson.value("currentWaypoint", 0);
taxiCar.waypointReachRadius = taxiCarJson.value("waypointReachRadius", 3.f);
taxiCar.wheelAngle = taxiCarJson.value("wheelAngle", 0.f);
//--------- Taxi initial state -----
//logger() << "LocationState::load step 6" << std::endl;
nlohmann::json taxiCarInitialStateJson = taxiJson["taxiCarInitialState"];
taxiCarInitialState.position = Eigen::Vector3f(
taxiCarInitialStateJson.value("positionX", 0.f),
taxiCarInitialStateJson.value("positionY", 0.f),
taxiCarInitialStateJson.value("positionZ", 0.f)
);
taxiCarInitialState.rotation = taxiCarInitialStateJson.value("rotation", 0.f);
taxiCarInitialState.velocity = taxiCarInitialStateJson.value("velocity", 0.f);
taxiCarInitialState.steeringAngle = taxiCarInitialStateJson.value("steeringAngle", 0.f);
taxiCarInitialState.mode = static_cast<NpcCar::Mode>(taxiCarInitialStateJson.value("mode", 0));
//logger() << "LocationState::load step 7" << std::endl;
taxiCarInitialState.waypoints.clear();
if (taxiCarInitialStateJson.contains("waypoints") && taxiCarInitialStateJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarInitialStateJson["waypoints"].size()); ++i)
taxiCarInitialState.waypoints.push_back(Eigen::Vector3f(
taxiCarInitialStateJson["waypoints"][i].value("x", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("y", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 8" << std::endl;
taxiCarInitialState.currentWaypoint = taxiCarInitialStateJson.value("currentWaypoint", 0);
taxiCarInitialState.waypointReachRadius = taxiCarInitialStateJson.value("waypointReachRadius", 3.f);
taxiCarInitialState.wheelAngle = taxiCarInitialStateJson.value("wheelAngle", 0.f);
taxiCarWaypoints.clear();
if (taxiJson.contains("taxiDefaultWaypoints") && taxiJson["taxiDefaultWaypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiJson["taxiDefaultWaypoints"].size()); ++i)
taxiCarWaypoints.push_back(Eigen::Vector3f(
taxiJson["taxiDefaultWaypoints"][i].value("x", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("y", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 9" << std::endl;
taxiCarTriggerPosition = Eigen::Vector3f(
taxiJson.value("taxiCarTriggerPositionX", 0.f),
taxiJson.value("taxiCarTriggerPositionY", 0.f),
taxiJson.value("taxiCarTriggerPositionZ", 0.f)
);
//logger() << "LocationState::load step 10" << std::endl;
targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string());
targetInteractNpcId = in.value("targetInteractNpcId", std::string());
targetTeleportZoneId = in.value("targetTeleportZoneId", std::string());
const auto& taxi = in.at("taxi");
loadCar(taxiCar, taxi.at("car"));
taxiArrivedCallbackCalled =
taxi.at("arrivedCallbackCalled").get<bool>();
}
} // namespace FRG
+46 -17
View File
@@ -2,34 +2,63 @@
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
#include "NpcCar.h"
#include "navigation/PathFinder.h"
#include <memory>
#include <string>
#include <unordered_map>
namespace FRG {
struct LocationState : public ISaveable {
// ---- Camera ----
float cameraAzimuth = -2.35f;
float cameraInclination = 1.1036f;
inline constexpr int kSinglePlayerPersistenceVersion = 1;
struct LocationLocalState : public ISaveable {
float cameraAzimuth = -2.35f;
float cameraInclination = 1.1036f;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
};
struct ClientLocationState : public ISaveable {
std::unordered_map<std::string, std::shared_ptr<LocationLocalState>> locations;
std::shared_ptr<LocationLocalState> getOrCreate(const std::string& locationId);
void clear();
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
};
void saveSinglePlayerPersistence(
const nlohmann::json& networkState,
const ClientLocationState& localState,
nlohmann::json& out);
void loadSinglePlayerPersistence(
const nlohmann::json& in,
nlohmann::json& networkState,
ClientLocationState& localState);
struct LocationNetworkState : public ISaveable {
//This value is populated from the JSON configs and should not be changed during the regular gameplay.
//It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const;
void initNavigation(
const std::vector<std::string>& paths,
const std::string& zipFile = std::string());
// ---- Navigation ----
int activeNavigationIndex = 0;
// ---- Walk-to targets (-1 = none) ----
// Mirrors CharacterState's kNoTarget convention; raw pointers are not serialisable.
int targetInteractiveObjectIndex = -1;
int targetInteractNpcIndex = -1;
int targetTeleportZoneIndex = -1;
// ---- World-mode flags (synced from Game after each transition) ----
bool isDarklands = false;
bool isNight = false;
bool isDawn = false;
// ---- Walk-to targets (empty = none) ----
std::string targetInteractiveObjectId;
std::string targetInteractNpcId;
std::string targetTeleportZoneId;
NpcCar taxiCarInitialState;
NpcCar taxiCar;
bool taxiArrivedCallbackCalled = false;
Vector3f taxiCarTriggerPosition = Vector3f(0,0,0);
std::vector<Vector3f> taxiCarWaypoints;
Eigen::Vector3f taxiCarTriggerPosition = Eigen::Vector3f(0,0,0);
std::vector<Eigen::Vector3f> taxiCarWaypoints;
// (trigger/teleport zone runtime flags are serialised by id directly in Location::save/load)
+131 -114
View File
@@ -201,9 +201,9 @@ namespace FRG {
}
#endif
MenuManager::MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer) :
MenuManager::MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer) :
renderer(iRenderer),
gameState_(gameState),
server_(server),
audioPlayer_(audioPlayer)
{
//Localization
@@ -338,9 +338,7 @@ namespace FRG {
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
}
void MenuManager::setup(Inventory& inv, const std::string& zipFile) {
inventory = &inv;
void MenuManager::setup(const std::string& zipFile) {
loadUiRoots(zipFile);
texObjectiveCompleted_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_completed.png", zipFile, true);
@@ -360,19 +358,6 @@ namespace FRG {
languageLoadingRu = renderer.textureManager.LoadFromPng("resources/loading_land003_ru.png", zipFile);
languageLoadingEn = renderer.textureManager.LoadFromPng("resources/loading_land003_en.png", zipFile);
gameState_.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestNew, q->definition.title);
};
gameState_.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestCompleted, q->definition.title);
};
gameState_.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestFailed, q->definition.title);
};
const std::string imgDir = "resources/w/ui/img/phone/";
texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true);
texBubbleInLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_top.png", zipFile, true);
@@ -419,9 +404,10 @@ namespace FRG {
}
std::shared_ptr<UiNode> MenuManager::gameplayHudRoot() const {
const std::string& loc = gameState_.currentLocationName;
if (loc == "uni_exterior") return gameState_.isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return gameState_.isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
const auto snapshot = server_.snapshot();
const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
return hudDormRoot; // dorm, or a new game before the location is set
}
@@ -434,21 +420,22 @@ namespace FRG {
}
void MenuManager::refreshHudVisibility() {
if (!inventory) return; // update() already ticks during loading, before setup()
const auto itemIds = server_.snapshot()->inventoryItemIds;
const TutorialStep step = gameState_.tutorialStep;
const bool tutorial = gameState_.isTutorialActive();
const bool hasPhone = inventory->hasItem("phone");
const bool hasJournal = inventory->hasItem("journal");
const bool hasNote = inventory->hasItem("note_spell");
const bool dark = gameState_.isDarklands;
const auto snapshot = server_.snapshot();
const TutorialStep step = snapshot->tutorialStep;
const bool tutorial = snapshot->isTutorialActive();
const bool hasPhone = std::find(itemIds.begin(), itemIds.end(), "phone") != itemIds.end();
const bool hasJournal = std::find(itemIds.begin(), itemIds.end(), "journal") != itemIds.end();
const bool hasNote = std::find(itemIds.begin(), itemIds.end(), "note_spell") != itemIds.end();
const bool dark = snapshot->isDarklands;
auto show = [this](std::initializer_list<const char*> names, bool visible) {
for (const char* name : names) uiManager.setNodeVisible(name, visible);
};
// --- HUD buttons: present while the matching item is in the inventory ---
show({ "inventoryButton" }, inventory->getCount() > 0);
show({ "inventoryButton" }, !itemIds.empty());
show({ "phoneButton" }, hasPhone && !dark);
show({ "journalButton" }, hasJournal);
show({ "darklandsButton" }, hasNote && !dark);
@@ -466,9 +453,9 @@ namespace FRG {
show({ "hint4" }, step == TutorialStep::Zoom);
show({ "hint5" }, step == TutorialStep::PickUpItem);
// ...then the hints for the picked-up items, which can be done in any order.
const bool chatHint = tutorial && !gameState_.tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !gameState_.tutorialJournalScreenOpened;
const bool taxiHint = tutorial && gameState_.tutorialNeedOpenTaxiScreen;
const bool chatHint = tutorial && !snapshot->tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !snapshot->tutorialJournalScreenOpened;
const bool taxiHint = tutorial && snapshot->tutorialNeedOpenTaxiScreen;
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
show({ "hint7", "hint7arrow" }, taxiHint);
@@ -485,7 +472,7 @@ namespace FRG {
}
void MenuManager::showMainMenu() {
gameState_.currentLocationName.clear();
server_.submit(SetCurrentLocationCommand{""});
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -546,7 +533,6 @@ namespace FRG {
}
void MenuManager::showMenuAfterGameCompleted() {
//gameState_.currentLocationName.clear();
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -1060,8 +1046,8 @@ namespace FRG {
steps.push_back([this]() {
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState_.campaignId))
gameState_.questJournal.reloadDefinitionsPreservingState(campaign->questsPath, CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(server_.snapshot()->campaignId))
server_.submit(ReloadQuestDefinitionsCommand{campaign->questsPath, CONST_ZIP_FILE});
});
steps.push_back([this]() {
@@ -1197,7 +1183,7 @@ namespace FRG {
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
const auto& items = inventory->getItems();
const auto items = inventoryItems();
/*logger() << "Item count: " << items.size() << std::endl;
for (int x = 0; x < items.size(); x++)
{
@@ -1236,7 +1222,7 @@ namespace FRG {
void MenuManager::selectInventoryItem(int index) {
logger() << "MenuManager::selectInventoryItem: " << index << std::endl;
const auto& items = inventory->getItems();
const auto items = inventoryItems();
if (index < 0 || index >= static_cast<int>(items.size())) return;
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
@@ -1288,7 +1274,10 @@ namespace FRG {
void MenuManager::openQuestJournal() {
uiState_ = GameUiState::QuestJournal;
gameState_.tutorialJournalScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
tutorial->tutorialPhoneChatScreenOpened, true,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
journalMenuStackDepth_ = 1;
const bool portrait = isPortraitMode();
@@ -1380,9 +1369,10 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
if (gameState_.isNight)
const auto timeState = server_.snapshot();
if (timeState->isNight)
{
if (gameState_.isDawn)
if (timeState->isDawn)
{
uiManager.setNodeVisible("phoneTimeDay", false);
uiManager.setNodeVisible("phoneTimeNight", false);
@@ -1418,51 +1408,67 @@ namespace FRG {
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
gameState_.chatUnread[0] = false;
setChatUnread(0, false);
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
gameState_.chatUnread[1] = false;
setChatUnread(1, false);
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
gameState_.chatUnread[2] = false;
setChatUnread(2, false);
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
}
void MenuManager::refreshChatUnreadIndicators() {
const auto state = server_.snapshot();
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
for (int i = 0; i < 3; ++i) {
uiManager.setNodeVisible(kUnreadNodes[i], gameState_.chatUnread[i]);
if (!gameState_.chatPreviewMsg[i].empty())
uiManager.setText(kMsgNodes[i], gameState_.chatPreviewMsg[i]);
uiManager.setNodeVisible(kUnreadNodes[i], state->chatUnread[i]);
if (!state->chatPreview[i].empty())
uiManager.setText(kMsgNodes[i], state->chatPreview[i]);
}
}
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
if (chatIndex < 0 || chatIndex > 2) return;
gameState_.chatUnread[chatIndex] = unread;
const auto state = server_.snapshot();
std::string preview = state->chatPreview[chatIndex];
if (!previewMsg.empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(previewMsg);
} else if (!unread && !gameState_.chatHistory[chatIndex].empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(gameState_.chatHistory[chatIndex].back().text);
preview = trimChatPreview(previewMsg);
} else if (!unread && !state->chatHistory[chatIndex].empty()) {
preview = trimChatPreview(state->chatHistory[chatIndex].back().text);
}
server_.submit(SetChatCommand{chatIndex, unread, std::move(preview)});
}
void MenuManager::spendMoney(int amount) {
gameState_.money -= amount;
server_.submit(SpendMoneyCommand{amount});
}
std::vector<Item> MenuManager::inventoryItems() const {
std::vector<Item> items;
for (const std::string& id : server_.snapshot()->inventoryItemIds) {
if (const Item* item = ItemRegistry::instance().findById(id)) items.push_back(*item);
}
return items;
}
bool MenuManager::hasInventoryItem(const std::string& id) const {
const auto& ids = server_.snapshot()->inventoryItemIds;
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
void MenuManager::openPhoneBank() {
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneBankRoot : phoneBankRoot);
uiManager.setText("balanceText", formatMoney(gameState_.money));
uiManager.setText("balanceText", formatMoney(server_.snapshot()->money));
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
@@ -1492,13 +1498,15 @@ namespace FRG {
});
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
closePhoneEntirely();
if (gameState_.isNight)
const auto state = server_.snapshot();
if (state->isNight)
{
startDialogueFunc("dialog_video002");
}
else
{
auto day = gameState_.globalInts["day"];
auto dayIt = state->globalInts.find("day");
const int day = dayIt == state->globalInts.end() ? 0 : dayIt->second;
if (day == 0)
{
startDialogueFunc("dialog_video003");
@@ -1513,10 +1521,11 @@ namespace FRG {
}
void MenuManager::openPhoneTaxi() {
if (gameState_.currentLocationName == "uni_interior") {
const std::string location = server_.snapshot()->currentLocationId;
if (location == "uni_interior") {
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
} else if (gameState_.currentLocationName == "uni_exterior") {
} else if (location == "uni_exterior") {
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
} else {
@@ -1539,17 +1548,18 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
gameState_.tutorialNeedOpenTaxiScreen = false;
gameState_.tutorialTaxiHintDone = true;
auto state = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
false, true});
if (gameState_.taxiIsCalled == false)
if (!state->taxiIsCalled)
{
gameState_.taxiIsCalled = true;
if (callTaxiFunc)
{
callTaxiFunc();
}
gameState_.money -= 500;
server_.submit(SetTaxiCalledCommand{true});
server_.submit(StartTaxiCommand{state->currentLocationId});
server_.submit(SetTriggerZoneEnabledCommand{
state->currentLocationId, "taxi_zone001", false});
server_.submit(SpendMoneyCommand{500});
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
@@ -1569,7 +1579,10 @@ namespace FRG {
activeChatIndex_ = chatIndex;
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
phoneChatVisibleBubbles_.clear();
gameState_.tutorialPhoneChatScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
true, tutorial->tutorialJournalScreenOpened,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
uiManager.pushMenuFromSavedRoot(chatRoot);
// Build a dedicated chatUiManager root sized to the FBO chat area
@@ -1616,11 +1629,7 @@ namespace FRG {
}
void MenuManager::returnToPhoneChatList() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
phoneChatVisibleBubbles_.clear();
@@ -1634,11 +1643,7 @@ namespace FRG {
void MenuManager::closePhoneEntirely() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::None;
uiState_ = GameUiState::Gameplay;
@@ -1699,8 +1704,11 @@ namespace FRG {
void MenuManager::tutorialShowTaxiHint()
{
logger() << "tutorialShowTaxiHint" << std::endl;
if (!gameState_.isTutorialActive() || gameState_.tutorialTaxiHintDone) return;
gameState_.tutorialNeedOpenTaxiScreen = true; // cleared when the taxi is ordered
auto state = server_.snapshot();
if (!state->isTutorialActive() || state->tutorialTaxiHintDone) return;
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
true, state->tutorialTaxiHintDone});
refreshHudVisibility();
}
@@ -1723,16 +1731,17 @@ namespace FRG {
void MenuManager::onLocationChanged(const std::string& locationName) {
if (uiState_ != GameUiState::Gameplay) return;
gameState_.currentLocationName = locationName;
server_.submit(SetCurrentLocationCommand{locationName});
const auto state = server_.snapshot();
showGameplayHud();
if (locationName == "uni_exterior" || locationName == "uni_interior") {
if (gameState_.isDarklands)
if (state->isDarklands)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
lastMusicTrack = "music/bishkek fight calm.ogg";
}
else if (gameState_.isNight)
else if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1743,7 +1752,7 @@ namespace FRG {
lastMusicTrack = "music/bishkek univer day.ogg";
}
} else {
if (gameState_.isNight)
if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1759,13 +1768,16 @@ namespace FRG {
void MenuManager::onItemPickedUp(const std::string& itemId) {
// The new item's HUD button appears through refreshHudVisibility().
if (itemId == "phone" || itemId == "journal")
gameState_.completeTutorialStep(TutorialStep::PickUpItem);
server_.submit(CompleteTutorialStepCommand{TutorialStep::PickUpItem});
refreshHudVisibility();
}
void MenuManager::refreshQuestJournalUi() {
visibleQuestIds.clear();
auto quests = gameState_.questJournal.getVisibleQuests();
const auto state = server_.snapshot();
std::vector<const Quest::QuestState*> quests;
quests.reserve(state->quests.size());
for (const auto& quest : state->quests) quests.push_back(&quest);
std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) {
const int pa = questStatusPriority(a->status);
@@ -1840,7 +1852,10 @@ namespace FRG {
}
selectedQuestIndex = index;
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) {
return;
}
@@ -1888,7 +1903,7 @@ namespace FRG {
}
void MenuManager::openPortraitInventoryItem(int index) {
const auto& items = inventory->getItems();
const auto items = inventoryItems();
inventorySelectedIndex_ = index;
inventoryMenuStackDepth_ = 2;
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
@@ -1943,7 +1958,10 @@ namespace FRG {
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) return;
const auto& def = quest->definition;
@@ -2070,7 +2088,8 @@ namespace FRG {
phoneChatVisibleBubbles_.clear();
if (chatIndex < 0 || chatIndex > 2) return;
for (const auto& msg : gameState_.chatHistory[chatIndex]) {
const auto state = server_.snapshot();
for (const auto& msg : state->chatHistory[chatIndex]) {
const bool inc = msg.incoming;
const std::string nodeName = chatUiManager.addChatBubble(
"chatMessagesContainer", msg.text, inc,
@@ -2100,11 +2119,7 @@ namespace FRG {
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
if (activeChatIndex_ < 0) return;
auto& history = gameState_.chatHistory[activeChatIndex_];
if (static_cast<int>(history.size()) >= 5) {
history.erase(history.begin());
}
history.push_back({ text, incoming });
server_.submit(AppendChatMessageCommand{activeChatIndex_, text, incoming});
if (uiState_ != GameUiState::PhoneScreen) return;
@@ -2143,8 +2158,8 @@ namespace FRG {
{
logger() << "MenuManager::setDarklandsMode called" << std::endl;
if (enabled && gameState_.currentLocationName == "uni_interior" && inventory->hasItem("note_spell"))
gameState_.completeTutorialStep(TutorialStep::EnterDarklands);
if (enabled && server_.snapshot()->currentLocationId == "uni_interior" && hasInventoryItem("note_spell"))
server_.submit(CompleteTutorialStepCommand{TutorialStep::EnterDarklands});
if (uiState_ == GameUiState::Gameplay)
showGameplayHud(); // darklands has its own HUD layouts
@@ -2152,30 +2167,31 @@ namespace FRG {
void MenuManager::onPlayerStartedWalking()
{
if (gameState_.currentLocationName == "uni_interior" && gameState_.isDarklands)
gameState_.completeTutorialStep(TutorialStep::DarklandsHealth);
const auto state = server_.snapshot();
if (state->currentLocationId == "uni_interior" && state->isDarklands)
server_.submit(CompleteTutorialStepCommand{TutorialStep::DarklandsHealth});
}
void MenuManager::advanceUniIntDarklandsHud()
{
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
if (gameState_.tutorialStep == TutorialStep::DarklandsHealth
|| gameState_.tutorialStep == TutorialStep::DarklandsExplore)
gameState_.tutorialStep = TutorialStep::DarklandsAttack;
const TutorialStep step = server_.snapshot()->tutorialStep;
if (step == TutorialStep::DarklandsHealth || step == TutorialStep::DarklandsExplore)
server_.submit(SetTutorialStepCommand{TutorialStep::DarklandsAttack});
}
void MenuManager::onEnemyKilledInUniInterior()
{
// Every location's enemies report here; only the university interior drives the tutorial.
if (gameState_.currentLocationName != "uni_interior") return;
const auto state = server_.snapshot();
if (state->currentLocationId != "uni_interior") return;
// The last tutorial step; like the attack hint, also ends the earlier darklands hints.
if (gameState_.tutorialStep >= TutorialStep::DarklandsHealth)
gameState_.tutorialStep = TutorialStep::Completed;
if (state->tutorialStep >= TutorialStep::DarklandsHealth)
server_.submit(SetTutorialStepCommand{TutorialStep::Completed});
}
void MenuManager::updateHealthBar(float hp, float maxHp) {
gameState_.playerHp = hp;
gameState_.playerMaxHp = maxHp;
server_.submit(SetPlayerHealthCommand{hp, maxHp});
applyCurrentHealthBar();
}
@@ -2191,21 +2207,22 @@ namespace FRG {
cutsceneHudActive_ = false;
topUiManager.replaceRoot(nullptr);
if (uiState_ == GameUiState::Gameplay)
onLocationChanged(gameState_.currentLocationName);
onLocationChanged(server_.snapshot()->currentLocationId);
}
void MenuManager::applyCurrentHealthBar() {
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
logger() << "currentPlayerMaxHp_ is " << gameState_.playerMaxHp << std::endl;
if (gameState_.playerMaxHp <= 0.f) return;
const auto state = server_.snapshot();
logger() << "currentPlayerMaxHp_ is " << state->playerMaxHp << std::endl;
if (state->playerMaxHp <= 0.f) return;
logger() << "MenuManager::applyCurrentHealthBar called step 2" << std::endl;
logger() << "currentPlayerHp_ is " << gameState_.playerHp << std::endl;
logger() << "currentPlayerHp_ is " << state->playerHp << std::endl;
const float fraction = std::clamp(gameState_.playerHp / gameState_.playerMaxHp, 0.f, 1.f);
const float fraction = std::clamp(state->playerHp / state->playerMaxHp, 0.f, 1.f);
uiManager.setSliderValue("healthBarFill", fraction);
std::string hpText = std::to_string(static_cast<int>(gameState_.playerHp)) + "/" +
std::to_string(static_cast<int>(gameState_.playerMaxHp));
std::string hpText = std::to_string(static_cast<int>(state->playerHp)) + "/" +
std::to_string(static_cast<int>(state->playerMaxHp));
if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' ');
uiManager.setText("healthValue", hpText);
}
@@ -2447,7 +2464,7 @@ namespace FRG {
openInventory();
const int itemCount = static_cast<int>(inventory->getItems().size());
const int itemCount = static_cast<int>(inventoryItems().size());
if (isPortraitMode() && wasOnItemDetail &&
savedIndex >= 0 && savedIndex < itemCount) {
selectInventoryItem(savedIndex);
+12 -11
View File
@@ -3,8 +3,9 @@
#include "Environment.h"
#include "render/TextureManager.h"
#include "UiManager.h"
#include "Localization.h"
#include "items/Item.h"
#include "GameState.h"
#include "game_server/IGameServer.h"
#include <vector>
#include <deque>
#include <string>
@@ -39,9 +40,9 @@ namespace FRG {
std::unordered_map<std::string,
std::unordered_map<Language, std::string>> localizedLocationName;
MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer);
MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer);
void setup(Inventory& inv, const std::string& zipFile);
void setup(const std::string& zipFile);
void openInventory();
void selectInventoryItem(int index);
@@ -56,6 +57,8 @@ namespace FRG {
void showMainMenu();
void showMenuAfterGameCompleted();
// Returns to the main menu with the campaign list already open.
void showCampaignScreen();
bool isMainMenuOpen() const { return uiState_ == GameUiState::MainMenu; }
void openPhoneScreen();
@@ -80,7 +83,7 @@ namespace FRG {
void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = "");
void spendMoney(int amount);
int getMoney() const { return gameState_.money; }
int getMoney() const { return server_.snapshot()->money; }
// Starts a new game of the given campaign (see CampaignRegistry).
std::function<void(const std::string& campaignId)> startGameFunc;
@@ -89,7 +92,6 @@ namespace FRG {
std::function<void()> startNightTransitionFunc;
std::function<void(int)> chatOpenCallback;
std::function<void()> skipCutsceneFunc;
std::function<void()> callTaxiFunc;
std::function<void(int)> onSaveGame;
std::function<void(int)> onLoadGame;
std::function<SaveSlotInfo(int)> getSlotInfoFunc;
@@ -172,14 +174,14 @@ namespace FRG {
Renderer& renderer;
private:
GameState& gameState_;
IGameServer& server_;
AudioPlayerAsync& audioPlayer_;
void startNewGame(const std::string& campaignId);
// HUD layout for the current location and darklands mode.
std::shared_ptr<UiNode> gameplayHudRoot() const;
// Single place that decides which HUD buttons and tutorial hints are visible:
// buttons follow the inventory, hints follow gameState_.tutorialStep and the
// buttons follow the inventory snapshot, hints follow the server tutorial state and
// tutorial flags. Runs every frame; names absent from the shown screen are skipped.
void refreshHudVisibility();
void refreshQuestJournalUi();
@@ -223,8 +225,6 @@ namespace FRG {
void showLanguageScreen();
void showLoadGameScreen();
void showSaveGameScreen();
// Campaign list opened by "Start game".
void showCampaignScreen();
// Text of a load/save slot button.
std::string slotLabel(const SaveSlotInfo& info);
@@ -249,7 +249,8 @@ namespace FRG {
static constexpr float TOAST_VISIBLE_MS = 2000.0f;
GameUiState uiState_ = GameUiState::Gameplay;
Inventory* inventory = nullptr;
std::vector<Item> inventoryItems() const;
bool hasInventoryItem(const std::string& id) const;
bool isPlayerInCreditsMenu = false;
int inventorySelectedIndex_ = -1;
@@ -336,7 +337,7 @@ namespace FRG {
enum class PhoneSubScreen { None, Main, Bank, Video, MapDorm, MapUni, ChatList, Chat };
PhoneSubScreen currentPhoneSubScreen_ = PhoneSubScreen::None;
// Phone chat UI state (transient — rebuilt from gameState_.chatHistory on open)
// Phone chat UI state (transient — rebuilt from the immutable server snapshot)
struct PhoneChatBubbleInfo {
std::string nodeName;
float height;
+3 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "render/Renderer.h"
#include "Environment.h"
#include <Eigen/Core>
#include <cstddef>
#include <vector>
namespace FRG {
struct NpcCar
+328 -347
View File
File diff suppressed because it is too large. Load diff
+9 -24
View File
@@ -1,9 +1,6 @@
#pragma once
#include <string>
#include <memory>
#include <unordered_map>
#include <functional>
#include "quest/QuestJournal.h"
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
#include "AudioPlayerAsync.h"
@@ -11,37 +8,33 @@
namespace FRG {
class Location;
class Inventory;
class ScriptEngine : public ISaveable {
class ScriptEngine {
public:
ScriptEngine();
~ScriptEngine();
// Must be called once, after the Game's NPCs are ready.
void init(Location* game, Inventory* inventory, AudioPlayerAsync* audioPlayer, const std::string& scriptPath);
void init(Location* game, AudioPlayerAsync* audioPlayer, const std::string& scriptPath);
// Execute a Lua script file. Logs errors to stderr.
//void runScript(const std::string& path);
void callItemPickupCallback(const std::string& objectName);
void callFunction(const std::string& functionName);
void callActivateFunction(const std::string& functionName);
void callNpcInteractCallback(int npcIndex);
void callNpcInteractCallback(const std::string& npcId);
void callTriggerEnterCallback(const std::string& zoneId);
void callTriggerExitCallback(const std::string& zoneId);
// Fired when the player (moving) physically bumps into a standing NPC.
void callNpcBumpedByPlayerCallback(int npcIndex);
void callNpcBumpedByPlayerCallback(const std::string& npcId);
// Fired when a moving NPC physically bumps into the standing player.
void callNpcBumpsPlayerCallback(int npcIndex);
void setGlobalStore(std::unordered_map<std::string, int>* store);
void setGlobalFloatStore(std::unordered_map<std::string, float>* store);
void setQuestJournal(Quest::QuestJournal* journal);
void callNpcBumpsPlayerCallback(const std::string& npcId);
void callLocationInitCallback();
void callLocationEnterCallback();
void callLocationExitCallback();
@@ -63,16 +56,8 @@ public:
// has already run and registered all callbacks.
void loadScriptGlobals(const nlohmann::json& in);
// Re-wires an in-flight, one-shot callback (NPC/player walk-arrival, object
// animation-complete) that was serialized by name rather than by value.
// Looks up `name` as a global Lua function and assigns a wrapped invoker to
// outCallback; leaves outCallback empty and logs an error if not found.
// Call after init() (the script must already have run) and after the owning
// state (CharacterState / InteractiveObjectState) has been loaded.
void rehydrateNamedCallback(const std::string& name, std::function<void()>& outCallback) const;
void save(nlohmann::json& out) const override { saveScriptGlobals(out); }
void load(const nlohmann::json& in) override { loadScriptGlobals(in); }
void save(nlohmann::json& out) const { saveScriptGlobals(out); }
void load(const nlohmann::json& in) { loadScriptGlobals(in); }
private:
struct Impl;
+3 -3
View File
@@ -51,13 +51,13 @@ namespace FRG {
std::string shaderProgramName;
// Sparks spawn within this radius of each emission point. 0 means
// "spawn exactly at the point" (legacy behavior).
// "spawn exactly at the point" (old behavior).
float emissionRadius;
bool configured;
// Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity,
// particles are rendered as axis-aligned quads in world XY and sorted by
// world Z (legacy behavior). Otherwise quads are billboarded toward the
// world Z (old behavior). Otherwise quads are billboarded toward the
// camera using its world-space right/up axes, and particles are sorted
// back-to-front in eye space for correct alpha blending.
void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix);
@@ -106,7 +106,7 @@ namespace FRG {
// Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO.
// Pass the current camera view matrix (world → eye) to billboard sparks
// toward the camera and sort them back-to-front. Identity (default)
// keeps the legacy non-billboarded behavior.
// keeps the old non-billboarded behavior.
void prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix = Eigen::Matrix4f::Identity());
void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight);
+2 -2
View File
@@ -287,10 +287,10 @@ namespace FRG {
float x1, u1;
if (fillMode) {
const float clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped <= 0.0f) {
/*if (clamped <= 0.0f) {
trackMesh.RefreshVBO();
return;
}
}*/
x1 = rect.x + rect.w * clamped;
u1 = clamped;
} else {
+427
View File
@@ -0,0 +1,427 @@
#include "LocationDefinition.h"
#include "../external/nlohmann/json.hpp"
#include "../utils/Utils.h"
#include <Eigen/Geometry>
#include <cmath>
#include <cstring>
#include <stdexcept>
#include <unordered_set>
namespace FRG {
namespace {
using json = nlohmann::json;
constexpr float kPi = 3.14159265358979323846f;
json loadJson(const std::string& path, const std::string& zipFile)
{
if (path.empty()) return json::object();
std::string content;
if (zipFile.empty()) {
content = readTextFile(path);
} else {
const auto bytes = readFileFromZIP(path, zipFile);
content.assign(bytes.begin(), bytes.end());
}
if (content.empty()) throw std::runtime_error("Failed to read " + path);
try {
return json::parse(content);
} catch (const std::exception& error) {
throw std::runtime_error(
"JSON parse error in " + path + ": " + error.what());
}
}
GameObjectData parseGameObject(const json& item)
{
GameObjectData data;
data.name = item.value("name", "Unknown");
data.texturePath = item.value("texturePath", "");
data.textureDarkandsPath = item.value("textureDarkandsPath", "");
data.meshPath = item.value("meshPath", "");
data.rotationX = item.value("rotationX", 0.f);
data.rotationY = item.value("rotationY", 0.f);
data.rotationZ = item.value("rotationZ", 0.f);
data.positionX = item.value("positionX", 0.f);
data.positionY = item.value("positionY", 0.f);
data.positionZ = item.value("positionZ", 0.f);
data.scale = item.value("scale", 1.f);
data.instanced = item.value("instanced", true);
return data;
}
Eigen::Vector3f vector(
const json& item, const char* x, const char* y, const char* z,
const Eigen::Vector3f& fallback = Eigen::Vector3f::Zero())
{
return {item.value(x, fallback.x()), item.value(y, fallback.y()),
item.value(z, fallback.z())};
}
std::pair<int, int> readBinaryAnimationMetadata(
const std::string& path, const std::string& zipFile)
{
const std::vector<char> bytes = zipFile.empty()
? readFile(path) : readFileFromZIP(path, zipFile);
if (bytes.size() < 8)
throw std::runtime_error("Invalid animation file " + path);
const char* cursor = bytes.data();
const char* end = cursor + bytes.size();
auto skip = [&](std::size_t size) {
if (size > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
cursor += size;
};
auto readU32 = [&]() {
std::uint32_t value = 0;
if (sizeof(value) > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
std::memcpy(&value, cursor, sizeof(value));
cursor += sizeof(value);
return value;
};
auto readI32 = [&]() {
std::int32_t value = 0;
if (sizeof(value) > static_cast<std::size_t>(end - cursor))
throw std::runtime_error("Unexpected EOF in " + path);
std::memcpy(&value, cursor, sizeof(value));
cursor += sizeof(value);
return value;
};
auto skipString = [&]() { skip(readU32()); };
if (std::memcmp(cursor, "BSMF", 4) != 0)
throw std::runtime_error("Invalid animation file " + path);
skip(4);
if (readU32() != 2)
throw std::runtime_error("Unsupported animation version in " + path);
skip(16 * sizeof(float));
const std::uint32_t boneCount = readU32();
for (std::uint32_t bone = 0; bone < boneCount; ++bone) {
skip(3 * sizeof(float) + sizeof(float) + 9 * sizeof(float));
skip(sizeof(std::int32_t));
skip(static_cast<std::size_t>(readU32()) * sizeof(std::int32_t));
}
for (std::uint32_t bone = 0; bone < boneCount; ++bone) skipString();
const std::uint32_t meshCount = readU32();
for (std::uint32_t mesh = 0; mesh < meshCount; ++mesh) {
skipString();
const std::uint32_t vertexCount = readU32();
skip(static_cast<std::size_t>(vertexCount) * 3 * sizeof(float));
const std::uint32_t faceCount = readU32();
skip(static_cast<std::size_t>(faceCount) * 3 * 2 * sizeof(float));
skip(static_cast<std::size_t>(vertexCount) * 3 * sizeof(float));
const std::uint32_t triangleCount = readU32();
skip(static_cast<std::size_t>(triangleCount) * 3 * sizeof(std::int32_t));
for (std::uint32_t vertex = 0; vertex < vertexCount; ++vertex)
skip(static_cast<std::size_t>(readU32()) *
(sizeof(std::int32_t) + sizeof(float)));
}
const std::uint32_t keyframeCount = readU32();
int lastFrame = 0;
for (std::uint32_t frame = 0; frame < keyframeCount; ++frame) {
lastFrame = readI32();
skip(static_cast<std::size_t>(boneCount) *
(3 * sizeof(float) + 16 * sizeof(float)));
}
return {0, keyframeCount == 0 ? 1 : lastFrame + 1};
}
} // namespace
LoadedGameObjectState GameObjectData::toLoadedGameObjectState() const
{
LoadedGameObjectState state;
state.texturePath = texturePath;
state.textureDarkandsPath = textureDarkandsPath;
state.meshPath = meshPath;
state.meshRotationX = rotationX;
state.meshRotationY = rotationY;
state.meshRotationZ = rotationZ;
state.meshScale = scale;
return state;
}
InteractiveObjectState InteractiveObjectData::toInteractiveObjectState() const
{
InteractiveObjectState state;
state.objectInfo = base.toLoadedGameObjectState();
state.interactionRadius = interactionRadius;
state.approachRadius = approachRadius;
state.castShadow = castShadow;
state.castShadowNight = castShadowNight;
state.isActive = isActive;
state.activateFunctionName = activateFunctionName;
state.pivot = pivot;
state.boundsMin = boundsMin;
state.boundsMax = boundsMax;
state.hasInteractionPosition = hasInteractionPosition;
state.interactionPosition = interactionPosition;
state.jsonPositionX = base.positionX;
state.jsonPositionY = base.positionY;
state.jsonPositionZ = base.positionZ;
state.position =
Eigen::Vector3f(base.positionX, base.positionY, base.positionZ);
return state;
}
CharacterState NpcData::toCharacterState() const
{
CharacterState state;
state.npcName = name;
state.position = {positionX, positionY, positionZ};
state.homePosition = {positionX, 0.f, positionZ};
state.stopInPlace();
state.facingAngle = facingAngle;
state.walkSpeed = walkSpeed;
state.rotationSpeed = rotationSpeed;
state.modelScale = modelScale;
state.setHp(hp);
state.initialHp = hp;
state.visibleRange = visibleRange;
state.canAttack = canAttack;
if (canAttack) state.attackTargetId = "player";
state.enabled = enabled;
state.interactionRadius = interactionRadius;
Eigen::Quaternionf correction = Eigen::Quaternionf::Identity();
if (modelCorrectionRotX != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotX, Eigen::Vector3f::UnitX())) * correction;
if (modelCorrectionRotY != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotY, Eigen::Vector3f::UnitY())) * correction;
if (modelCorrectionRotZ != 0.f)
correction = Eigen::Quaternionf(Eigen::AngleAxisf(
modelCorrectionRotZ, Eigen::Vector3f::UnitZ())) * correction;
state.modelCorrectionRotation = correction;
auto addAnimation = [&](AnimationState animation, const std::string& path) {
if (!path.empty())
state.creationInfo.animationPaths.emplace_back(animation, path);
};
addAnimation(AnimationState::STAND, animationIdlePath);
addAnimation(AnimationState::WALK, animationWalkPath);
addAnimation(AnimationState::ACTION_IDLE, animationActionIdlePath);
addAnimation(AnimationState::ACTION_ATTACK, animationActionAttackPath);
addAnimation(AnimationState::STAND_TO_ACTION, animationStandToActionPath);
addAnimation(AnimationState::ACTION_TO_STAND, animationActionToStandPath);
addAnimation(AnimationState::ACTION_TO_DEATH, animationActionToDeathPath);
addAnimation(AnimationState::DEATH_IDLE, animationDeathIdlePath);
state.creationInfo.meshTexturePaths = {
meshTextures.begin(), meshTextures.end()};
state.creationInfo.singleTexturePath = texturePath;
return state;
}
void loadCharacterAnimationMetadata(
CharacterState& state, const std::string& zipFile)
{
for (const auto& [animation, path] : state.creationInfo.animationPaths) {
if (path.size() < 5 || path.substr(path.size() - 5) != ".anim")
continue;
try {
const auto [startFrame, frameCount] =
readBinaryAnimationMetadata(path, zipFile);
state.setAnimationMetadata(animation, startFrame, frameCount);
} catch (const std::exception& error) {
logger() << "[content] Animation metadata failed for " << path
<< ": " << error.what() << '\n';
}
}
}
LocationDefinition LocationContentLoader::load(
const LocationConfig& config, const std::string& zipFile)
{
LocationDefinition result;
result.config = config;
if (!config.gameObjectsJsonPath.empty()) {
const json root = loadJson(config.gameObjectsJsonPath, zipFile);
for (const auto& item : root.value("objects", json::array())) {
GameObjectData data = parseGameObject(item);
if (!data.meshPath.empty())
result.staticObjects.push_back(std::move(data));
}
}
if (!config.interactiveObjectsJsonPath.empty()) {
const json root = loadJson(config.interactiveObjectsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("objects", json::array())) {
InteractiveObjectData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate interactive-object id in " +
config.interactiveObjectsJsonPath);
data.base = parseGameObject(item);
data.interactionRadius = item.value("interactionRadius", 2.f);
data.approachRadius =
item.value("approachRadius", data.interactionRadius);
data.activateFunctionName =
item.value("activateFunction", "");
data.pivot = vector(item, "pivotX", "pivotY", "pivotZ");
data.boundsMin =
vector(item, "boundsMinX", "boundsMinY", "boundsMinZ");
data.boundsMax =
vector(item, "boundsMaxX", "boundsMaxY", "boundsMaxZ");
data.hasInteractionPosition =
item.contains("interactionPositionX");
if (data.hasInteractionPosition) {
data.interactionPosition = vector(
item, "interactionPositionX", "interactionPositionY",
"interactionPositionZ");
}
data.castShadow = item.value("castShadow", true);
data.castShadowNight = item.value("castShadowNight", true);
data.isActive = item.value("isActive", true);
result.interactiveObjects.push_back(std::move(data));
}
}
if (!config.npcsJsonPath.empty()) {
const json root = loadJson(config.npcsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("npcs", json::array())) {
NpcData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || data.id == "player" ||
!ids.insert(data.id).second) {
throw std::runtime_error(
"Missing, duplicate, or reserved NPC id in " +
config.npcsJsonPath);
}
data.name = item.value("name", "Unknown NPC");
data.texturePath = item.value("texturePath", "");
if (item.contains("meshTextures") &&
item["meshTextures"].is_object()) {
for (const auto& [name, path] :
item["meshTextures"].items()) {
if (path.is_string())
data.meshTextures[name] = path.get<std::string>();
}
}
data.interactionRadius =
item.value("interactionRadius", 0.f);
data.animationIdlePath =
item.value("animationIdlePath", "");
data.animationWalkPath =
item.value("animationWalkPath", "");
data.animationActionIdlePath =
item.value("animationActionIdlePath", "");
data.animationActionAttackPath =
item.value("animationActionAttackPath", "");
data.animationStandToActionPath =
item.value("animationStandToActionPath", "");
data.animationActionToStandPath =
item.value("animationActionToStandPath", "");
data.animationActionToDeathPath =
item.value("animationActionToDeathPath", "");
data.animationDeathIdlePath =
item.value("animationDeathIdlePath", "");
data.positionX = item.value("positionX", 0.f);
data.positionY = item.value("positionY", 0.f);
data.positionZ = item.value("positionZ", 0.f);
data.walkSpeed = item.value("walkSpeed", 1.5f);
data.rotationSpeed = item.value("rotationSpeed", 8.f);
data.modelScale = item.value("modelScale", 0.01f);
const float degrees = kPi / 180.f;
data.modelCorrectionRotX =
item.value("modelCorrectionRotX", 0.f) * degrees;
data.modelCorrectionRotY =
item.value("modelCorrectionRotY", 0.f) * degrees;
data.modelCorrectionRotZ =
item.value("modelCorrectionRotZ", 0.f) * degrees;
data.facingAngle =
item.value("facingAngle", 0.f) * degrees;
data.hp = item.value("hp", 100.f);
data.visibleRange =
item.value("visibleRange", VISIBLE_RANGE);
data.canAttack = item.value("canAttack", false);
data.enabled = item.value("enabled", true);
result.npcs.push_back(std::move(data));
}
}
if (!config.teleportsJsonPath.empty()) {
const json root = loadJson(config.teleportsJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item : root.value("teleports", json::array())) {
TeleportDefinition data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate teleport id in " +
config.teleportsJsonPath);
data.active = item.value("active", false);
data.position =
vector(item, "positionX", "positionY", "positionZ");
data.radius = item.value("radius", 0.f);
data.automatic = item.value("automatic", false);
data.automaticRadius =
item.value("automaticRadius", data.radius);
data.destinationLocation =
item.value("destinationLocation", "");
data.destinationPosition = vector(
item, "destinationPositionX", "destinationPositionY",
"destinationPositionZ");
data.destinationRotationY =
item.value("destinationRotationY", 0.f);
result.teleports.push_back(std::move(data));
}
}
if (!config.triggerZonesJsonPath.empty()) {
const json root = loadJson(config.triggerZonesJsonPath, zipFile);
std::unordered_set<std::string> ids;
for (const auto& item :
root.value("trigger_zones", json::array())) {
TriggerZoneDefinition data;
data.id = item.at("id").get<std::string>();
if (data.id.empty() || !ids.insert(data.id).second)
throw std::runtime_error(
"Missing or duplicate trigger-zone id in " +
config.triggerZonesJsonPath);
data.enabled = item.value("enabled", true);
data.radius = item.value("radius", 1.5f);
data.hysteresis = item.value("hysteresis", 0.3f);
data.position =
vector(item, "positionX", "positionY", "positionZ");
result.triggerZones.push_back(std::move(data));
}
}
if (!config.lightsJsonPath.empty()) {
const json root = loadJson(config.lightsJsonPath, zipFile);
for (const auto& item : root.value("lights", json::array())) {
PointLightDefinition data;
data.id = item.value("id", "");
data.position =
vector(item, "positionX", "positionY", "positionZ");
data.direction = vector(
item, "directionX", "directionY", "directionZ",
Eigen::Vector3f(0.f, -1.f, 0.f));
data.color = vector(
item, "colorR", "colorG", "colorB",
Eigen::Vector3f::Ones());
data.autoLight = item.value("autoLight", false);
data.autoLightDistance =
item.value("autoLightDistance", -1.f);
data.limitX = item.value("limitX", 99999.f);
data.limitY = item.value("limitY", 99999.f);
data.autoLightLimitX =
item.value("autoLightLimitX", -1.f);
data.autoLightLimitY =
item.value("autoLightLimitY", -1.f);
result.pointLights.push_back(std::move(data));
}
}
return result;
}
} // namespace FRG
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include "../Campaign.h"
#include "../CharacterState.h"
#include "../items/InteractiveObjectState.h"
#include <Eigen/Core>
#include <map>
#include <string>
#include <vector>
namespace FRG {
struct GameObjectData {
std::string name;
std::string texturePath;
std::string textureDarkandsPath;
std::string meshPath;
float rotationX = 0.f;
float rotationY = 0.f;
float rotationZ = 0.f;
float positionX = 0.f;
float positionY = 0.f;
float positionZ = 0.f;
float scale = 1.f;
bool instanced = true;
LoadedGameObjectState toLoadedGameObjectState() const;
};
struct InteractiveObjectData {
std::string id;
GameObjectData base;
float interactionRadius = 2.f;
float approachRadius = 2.f;
std::string activateFunctionName;
Eigen::Vector3f pivot = Eigen::Vector3f::Zero();
Eigen::Vector3f boundsMin = Eigen::Vector3f::Zero();
Eigen::Vector3f boundsMax = Eigen::Vector3f::Zero();
bool hasInteractionPosition = false;
Eigen::Vector3f interactionPosition = Eigen::Vector3f::Zero();
bool castShadow = true;
bool castShadowNight = true;
bool isActive = true;
InteractiveObjectState toInteractiveObjectState() const;
};
struct NpcData {
std::string id;
std::string name;
std::string texturePath;
std::map<std::string, std::string> meshTextures;
std::string animationIdlePath;
std::string animationWalkPath;
std::string animationActionIdlePath;
std::string animationActionAttackPath;
std::string animationStandToActionPath;
std::string animationActionToStandPath;
std::string animationActionToDeathPath;
std::string animationDeathIdlePath;
float positionX = 0.f;
float positionY = 0.f;
float positionZ = 0.f;
float walkSpeed = 1.5f;
float rotationSpeed = 8.f;
float modelScale = 0.01f;
float modelCorrectionRotX = 0.f;
float modelCorrectionRotY = 0.f;
float modelCorrectionRotZ = 0.f;
float facingAngle = 0.f;
float hp = 100.f;
float visibleRange = VISIBLE_RANGE;
bool canAttack = false;
bool enabled = true;
float interactionRadius = 0.f;
CharacterState toCharacterState() const;
};
struct TeleportDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 0.f;
bool active = false;
std::string destinationLocation;
Eigen::Vector3f destinationPosition = Eigen::Vector3f::Zero();
float destinationRotationY = 0.f;
bool automatic = false;
float automaticRadius = 0.f;
};
struct TriggerZoneDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 1.5f;
float hysteresis = 0.3f;
bool enabled = true;
};
struct PointLightDefinition {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
Eigen::Vector3f direction = Eigen::Vector3f(0.f, -1.f, 0.f);
Eigen::Vector3f color = Eigen::Vector3f::Ones();
bool autoLight = false;
float autoLightDistance = -1.f;
float limitX = 99999.f;
float limitY = 99999.f;
float autoLightLimitX = -1.f;
float autoLightLimitY = -1.f;
};
struct LocationDefinition {
LocationConfig config;
std::vector<GameObjectData> staticObjects;
std::vector<InteractiveObjectData> interactiveObjects;
std::vector<NpcData> npcs;
std::vector<TeleportDefinition> teleports;
std::vector<TriggerZoneDefinition> triggerZones;
std::vector<PointLightDefinition> pointLights;
};
class LocationContentLoader {
public:
static LocationDefinition load(
const LocationConfig& config, const std::string& zipFile = {});
};
void loadCharacterAnimationMetadata(
CharacterState& state, const std::string& zipFile = {});
} // namespace FRG
+20 -14
View File
@@ -162,21 +162,24 @@ void DialogueRuntime::selectChoice(int index) {
}
void DialogueRuntime::setFlag(const std::string& name, int value) {
if (flagStore) (*flagStore)[name] = value;
if (setGlobalFlag) setGlobalFlag(name, value);
}
int DialogueRuntime::getFlag(const std::string& name) const {
if (!flagStore) return 0;
auto it = flagStore->find(name);
return (it != flagStore->end()) ? it->second : 0;
return getGlobalFlag ? getGlobalFlag(name) : 0;
}
void DialogueRuntime::setGlobalFlagStore(std::unordered_map<std::string, int>* store) {
flagStore = store;
void DialogueRuntime::setGlobalFlagAccessors(
std::function<int(const std::string&)> getter,
std::function<void(const std::string&, int)> setter)
{
getGlobalFlag = std::move(getter);
setGlobalFlag = std::move(setter);
}
void DialogueRuntime::setQuestJournal(Quest::QuestJournal* journal) {
questJournal = journal;
void DialogueRuntime::setQuestActionHandler(
std::function<void(const QuestActionRequest&)> handler) {
onQuestAction = std::move(handler);
}
void DialogueRuntime::applyQuestActions(
@@ -184,17 +187,20 @@ void DialogueRuntime::applyQuestActions(
const std::string& questFail, const std::string& objectiveComplete,
const std::string& objectiveVisible)
{
if (!questJournal) return;
if (!questUnlock.empty()) questJournal->unlockQuest(questUnlock);
if (!questComplete.empty()) questJournal->completeQuest(questComplete);
if (!questFail.empty()) questJournal->failQuest(questFail);
if (!onQuestAction) return;
if (!questUnlock.empty())
onQuestAction({QuestActionType::Unlock, questUnlock, {}});
if (!questComplete.empty())
onQuestAction({QuestActionType::Complete, questComplete, {}});
if (!questFail.empty())
onQuestAction({QuestActionType::Fail, questFail, {}});
if (!objectiveComplete.empty()) {
auto [qId, oId] = splitDot(objectiveComplete);
questJournal->setObjectiveCompleted(qId, oId);
onQuestAction({QuestActionType::CompleteObjective, qId, oId});
}
if (!objectiveVisible.empty()) {
auto [qId, oId] = splitDot(objectiveVisible);
questJournal->setObjectiveVisible(qId, oId);
onQuestAction({QuestActionType::ShowObjective, qId, oId});
}
}
+22 -6
View File
@@ -2,17 +2,29 @@
#include "dialogue/DialogueDatabase.h"
#include "dialogue/TranslationDatabase.h"
#include "quest/QuestJournal.h"
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace FRG::Dialogue {
enum class QuestActionType {
Unlock,
Complete,
Fail,
CompleteObjective,
ShowObjective
};
struct QuestActionRequest {
QuestActionType type;
std::string questId;
std::string objectiveId;
};
class DialogueRuntime : public FRG::ISaveable {
public:
void setDatabase(const DialogueDatabase* value);
@@ -41,8 +53,11 @@ public:
void setFlag(const std::string& name, int value);
int getFlag(const std::string& name) const;
void setGlobalFlagStore(std::unordered_map<std::string, int>* store);
void setQuestJournal(Quest::QuestJournal* journal);
void setGlobalFlagAccessors(
std::function<int(const std::string&)> getter,
std::function<void(const std::string&, int)> setter);
void setQuestActionHandler(
std::function<void(const QuestActionRequest&)> handler);
// Saves whether a dialogue is active, its id, and current node.
// Cutscene state is not saved.
@@ -62,13 +77,14 @@ private:
std::function<void(const std::string&, const std::string&)> onCutsceneStartNeeded;
std::function<void(const std::string&)> onDialogueLineStarted;
std::function<void(const std::string&, bool)> onChatBubbleReady;
std::function<void(const QuestActionRequest&)> onQuestAction;
const DialogueDatabase* database = nullptr;
const TranslationDatabase* translations = nullptr;
Quest::QuestJournal* questJournal = nullptr;
const DialogueDefinition* activeDialogue = nullptr;
std::unordered_map<std::string, int>* flagStore = nullptr;
std::function<int(const std::string&)> getGlobalFlag;
std::function<void(const std::string&, int)> setGlobalFlag;
std::unordered_set<std::string> consumedChoices;
std::string currentNodeId;
+11 -8
View File
@@ -6,7 +6,6 @@
#include "cutscene/CutsceneDatabase.h"
#include "cutscene/CutsceneOverlay.h"
#include "cutscene/CutsceneRuntime.h"
#include "quest/QuestJournal.h"
#include "ISaveable.h"
#include <SDL.h>
#include <functional>
@@ -14,7 +13,7 @@
namespace FRG::Dialogue {
class DialogueSystem : public FRG::ISaveable {
class DialogueSystem {
public:
bool init(Renderer& renderer, const std::string& zipFile = "");
@@ -48,8 +47,8 @@ public:
void saveDialogueState(nlohmann::json& out) const { dialogueRuntime.save(out); }
void loadDialogueState(const nlohmann::json& in) { dialogueRuntime.load(in); }
void save(nlohmann::json& out) const override { saveDialogueState(out); }
void load(const nlohmann::json& in) override { loadDialogueState(in); }
void save(nlohmann::json& out) const { saveDialogueState(out); }
void load(const nlohmann::json& in) { loadDialogueState(in); }
bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); }
bool isDialogueActive() const { return dialogueRuntime.isActive(); }
@@ -58,11 +57,15 @@ public:
void setFlag(const std::string& name, int value) { dialogueRuntime.setFlag(name, value); }
int getFlag(const std::string& name) const { return dialogueRuntime.getFlag(name); }
void setGlobalFlagStore(std::unordered_map<std::string, int>* store) {
dialogueRuntime.setGlobalFlagStore(store);
void setGlobalFlagAccessors(
std::function<int(const std::string&)> getter,
std::function<void(const std::string&, int)> setter) {
dialogueRuntime.setGlobalFlagAccessors(
std::move(getter), std::move(setter));
}
void setQuestJournal(Quest::QuestJournal* journal) {
dialogueRuntime.setQuestJournal(journal);
void setQuestActionHandler(
std::function<void(const QuestActionRequest&)> handler) {
dialogueRuntime.setQuestActionHandler(std::move(handler));
}
private:
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "IGameServerHost.h"
#include "../Location.h"
#include <utility>
namespace FRG {
class GameServerHostRouter final : public IGameServerHost {
public:
explicit GameServerHostRouter(IGameServerHost& initial)
: active_(&initial)
{
}
void use(IGameServerHost& host) { active_ = &host; }
void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) override {
active_->initializeCampaign(campaignId, questsPath, zipFile);
}
void clearCampaign() override { active_->clearCampaign(); }
void resetCampaign(
const std::string& questsPath, const std::string& zipFile) override {
active_->resetCampaign(questsPath, zipFile);
}
bool prepareLocation(const LocationConfig& config) override {
return active_->prepareLocation(config);
}
void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) override {
active_->setupRuntimeLocation(
id, location, std::move(params), audioPlayer);
}
Location* runtimeLocation(const std::string& id) const override {
return active_->runtimeLocation(id);
}
bool hasRuntimeLocation(const std::string& id) const override {
return active_->hasRuntimeLocation(id);
}
std::vector<std::string> loadedRuntimeLocationIds() const override {
return active_->loadedRuntimeLocationIds();
}
void clearRuntimeLocations(bool retainState) override {
active_->clearRuntimeLocations(retainState);
}
void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) override {
active_->bindRuntimeLocation(id, std::move(location));
}
void update(std::int64_t deltaMs) override { active_->update(deltaMs); }
void save(nlohmann::json& out) override { active_->save(out); }
void load(const nlohmann::json& in) override { active_->load(in); }
private:
IGameServerHost* active_;
};
} // namespace FRG
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "IGameServer.h"
#include <stdexcept>
namespace FRG {
// Keeps references held by UI and runtime objects stable while campaigns switch
// between the local and remote implementations.
class GameServerRouter final : public IGameServer {
public:
explicit GameServerRouter(IGameServer& initial)
: active_(&initial)
{
}
void use(IGameServer& server) { active_ = &server; }
std::shared_ptr<const GameSnapshot> snapshot() const override {
return active_->snapshot();
}
const std::string& localPlayerId() const override {
return active_->localPlayerId();
}
void submit(const GameCommand& command) override { active_->submit(command); }
std::vector<GameServerEvent> drainEvents() override {
return active_->drainEvents();
}
void update(std::int64_t deltaMs) override { active_->update(deltaMs); }
void save(nlohmann::json& out) const override { active_->save(out); }
void load(const nlohmann::json& in) override { active_->load(in); }
private:
IGameServer* active_;
};
} // namespace FRG
+287
View File
@@ -0,0 +1,287 @@
#pragma once
#include "../GameState.h"
#include "../external/nlohmann/json.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <variant>
#include <vector>
namespace FRG {
// Value-only view of authoritative state. It deliberately contains no runtime
// pointers, callbacks, renderer objects, or mutable references, so the same
// contract can later be transported over a network.
struct ChatMessageSnapshot {
std::string text;
bool incoming = false;
};
struct CharacterSnapshot {
std::string id;
CharacterState state;
};
struct InteractiveObjectSnapshot {
std::string id;
InteractiveObjectState state;
};
struct LocationSnapshot {
std::string id;
LocationNetworkState state;
std::unordered_map<std::string, CharacterSnapshot> npcs;
std::unordered_map<std::string, InteractiveObjectSnapshot> interactiveObjects;
std::unordered_map<std::string, TriggerZoneGameState> triggerZones;
std::unordered_map<std::string, bool> teleportZones;
nlohmann::json dialogue;
nlohmann::json scriptGlobals;
};
struct GameSnapshot {
std::uint64_t revision = 0;
std::string campaignId;
std::string currentLocationId;
bool isDarklands = false;
bool isNight = false;
bool isDawn = false;
float playerHp = 0.f;
float playerMaxHp = 0.f;
int money = 0;
TutorialStep tutorialStep = TutorialStep::Completed;
bool tutorialPhoneChatScreenOpened = false;
bool tutorialJournalScreenOpened = false;
bool tutorialNeedOpenTaxiScreen = false;
bool tutorialTaxiHintDone = false;
bool taxiIsCalled = false;
bool chatUnread[3] = {};
std::string chatPreview[3];
std::vector<ChatMessageSnapshot> chatHistory[3];
std::vector<std::string> inventoryItemIds;
nlohmann::json questJournal;
std::unordered_map<std::string, int> globalInts;
std::unordered_map<std::string, float> globalFloats;
std::unordered_map<std::string, CharacterSnapshot> players;
std::unordered_map<std::string, LocationSnapshot> locations;
std::vector<Quest::QuestState> quests;
bool isTutorialActive() const { return tutorialStep != TutorialStep::Completed; }
};
struct SetWorldModeCommand { bool darklands; bool night; bool dawn; };
struct SetCurrentLocationCommand { std::string locationId; };
struct SetPlayerHealthCommand { float hp; float maxHp; };
struct SetMoneyCommand { int amount; };
struct SpendMoneyCommand { int amount; };
struct CompleteTutorialStepCommand { TutorialStep step; };
struct SetTutorialStepCommand { TutorialStep step; };
struct SetTutorialFlagsCommand {
bool phoneChatOpened;
bool journalOpened;
bool needTaxiHint;
bool taxiHintDone;
};
struct SetChatCommand {
int index;
bool unread;
std::string preview;
};
struct AppendChatMessageCommand {
int index;
std::string text;
bool incoming;
};
struct SetTaxiCalledCommand { bool called; };
struct StartTaxiCommand { std::string locationId; };
struct ResetTaxiCommand { std::string locationId; };
struct SetLocationTargetsCommand {
std::string locationId;
std::string interactiveObjectId;
std::string npcId;
std::string teleportZoneId;
};
struct SwitchLocationNavigationCommand {
std::string locationId;
int index;
};
struct SetNavigationAreaAvailableCommand {
std::string locationId;
std::string areaName;
bool available;
};
struct AddNavigationObstacleCommand {
std::string locationId;
std::string name;
std::vector<Eigen::Vector2f> polygon;
};
struct AddInventoryItemCommand { std::string itemId; };
struct RemoveInventoryItemCommand { std::string itemId; };
struct UnlockQuestCommand { std::string questId; };
struct CompleteQuestCommand { std::string questId; };
struct FailQuestCommand { std::string questId; };
struct SetQuestObjectiveCompletedCommand {
std::string questId;
std::string objectiveId;
bool completed;
};
struct SetQuestObjectiveVisibleCommand {
std::string questId;
std::string objectiveId;
bool visible;
};
struct SetQuestActiveObjectiveCommand {
std::string questId;
int objectiveIndex;
};
struct SetGlobalIntCommand { std::string key; int value; };
struct SetGlobalFloatCommand { std::string key; float value; };
struct SetCharacterTransformCommand {
std::string locationId;
std::string characterId;
Eigen::Vector3f position;
float facingAngle;
};
struct SetCharacterTargetCommand {
std::string locationId;
std::string characterId;
std::string targetId;
};
struct ClearCharacterAttackTargetCommand {
std::string locationId;
std::string characterId;
};
struct SetCharacterDestinationCommand {
std::string locationId;
std::string characterId;
Eigen::Vector3f target;
std::string callbackName;
};
struct StopCharacterCommand {
std::string locationId;
std::string characterId;
};
struct FaceCharacterCommand {
std::string locationId;
std::string characterId;
std::string targetId;
};
struct ReplanCharacterCommand {
std::string locationId;
std::string characterId;
};
struct ResetCharacterAfterDeathCommand {
std::string locationId;
std::string characterId;
};
struct TryCharacterAttackComboCommand {
std::string locationId;
std::string characterId;
};
struct SetCharacterHealthCommand {
std::string locationId;
std::string characterId;
float hp;
};
struct SetCharacterAnimationMetadataCommand {
std::string locationId;
std::string characterId;
AnimationState animationState;
int startFrame;
int frameCount;
};
struct DamageCharacterCommand {
std::string locationId;
std::string characterId;
float amount;
std::string attackerId;
};
struct CommitCharacterStateCommand {
std::string locationId;
std::string characterId;
CharacterState state;
};
struct SetInteractiveObjectActiveCommand {
std::string locationId;
std::string objectId;
bool active;
};
struct SetTriggerZoneEnabledCommand {
std::string locationId;
std::string zoneId;
bool enabled;
};
struct ReloadQuestDefinitionsCommand { std::string path; std::string zipFile; };
using GameCommand = std::variant<
SetWorldModeCommand,
SetCurrentLocationCommand,
SetPlayerHealthCommand,
SetMoneyCommand,
SpendMoneyCommand,
CompleteTutorialStepCommand,
SetTutorialStepCommand,
SetTutorialFlagsCommand,
SetChatCommand,
AppendChatMessageCommand,
SetTaxiCalledCommand,
StartTaxiCommand,
ResetTaxiCommand,
SetLocationTargetsCommand,
SwitchLocationNavigationCommand,
SetNavigationAreaAvailableCommand,
AddNavigationObstacleCommand,
AddInventoryItemCommand,
RemoveInventoryItemCommand,
UnlockQuestCommand,
CompleteQuestCommand,
FailQuestCommand,
SetQuestObjectiveCompletedCommand,
SetQuestObjectiveVisibleCommand,
SetQuestActiveObjectiveCommand,
SetGlobalIntCommand,
SetGlobalFloatCommand,
SetCharacterTransformCommand,
SetCharacterTargetCommand,
ClearCharacterAttackTargetCommand,
SetCharacterDestinationCommand,
StopCharacterCommand,
FaceCharacterCommand,
ReplanCharacterCommand,
ResetCharacterAfterDeathCommand,
TryCharacterAttackComboCommand,
SetCharacterHealthCommand,
SetCharacterAnimationMetadataCommand,
DamageCharacterCommand,
CommitCharacterStateCommand,
SetInteractiveObjectActiveCommand,
SetTriggerZoneEnabledCommand,
ReloadQuestDefinitionsCommand>;
struct InventoryItemAddedEvent { std::string itemId; };
struct InventoryItemRemovedEvent { std::string itemId; };
using GameServerEvent = std::variant<
InventoryItemAddedEvent,
InventoryItemRemovedEvent,
Quest::QuestEvent>;
class IGameServer {
public:
virtual ~IGameServer() = default;
// Each returned object is immutable and remains valid after later commands.
virtual std::shared_ptr<const GameSnapshot> snapshot() const = 0;
// Client-relative identity assigned by this server connection.
virtual const std::string& localPlayerId() const = 0;
virtual void submit(const GameCommand& command) = 0;
virtual std::vector<GameServerEvent> drainEvents() = 0;
virtual void update(std::int64_t deltaMs) = 0;
// Persistence stays server-side. The JSON shape is GameState's established
// save schema and is intentionally not the wire snapshot shape.
virtual void save(nlohmann::json& out) const = 0;
virtual void load(const nlohmann::json& in) = 0;
};
} // namespace FRG
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "IGameServer.h"
#include <memory>
#include <string>
#include <vector>
class AudioPlayerAsync;
namespace FRG {
class Location;
struct LocationConfig;
struct LocationSetup;
// Client-only lifecycle and presentation integration for the in-process server.
// The public gameplay contract remains IGameServer and is graphics-free.
class IGameServerHost {
public:
virtual ~IGameServerHost() = default;
virtual void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) = 0;
virtual void clearCampaign() = 0;
virtual void resetCampaign(
const std::string& questsPath, const std::string& zipFile) = 0;
virtual bool prepareLocation(const LocationConfig& config) = 0;
virtual void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) = 0;
virtual Location* runtimeLocation(const std::string& id) const = 0;
virtual bool hasRuntimeLocation(const std::string& id) const = 0;
virtual std::vector<std::string> loadedRuntimeLocationIds() const = 0;
virtual void clearRuntimeLocations(bool retainState) = 0;
virtual void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) = 0;
virtual void update(std::int64_t deltaMs) = 0;
virtual void save(nlohmann::json& out) = 0;
virtual void load(const nlohmann::json& in) = 0;
};
} // namespace FRG
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
namespace FRG {
struct CampaignConfig;
struct LocationConfig;
// Graphics-free administrative lifecycle used by local and standalone hosts.
class IGameServerLifecycle {
public:
virtual ~IGameServerLifecycle() = default;
virtual void startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot) = 0;
virtual bool prepareLocation(const LocationConfig& config) = 0;
virtual void activateLocation(const std::string& locationId) = 0;
};
} // namespace FRG
+534
View File
@@ -0,0 +1,534 @@
#include "InternalGameServer.h"
#include "../Campaign.h"
#include "../content/LocationDefinition.h"
#include <algorithm>
#include <random>
#include <type_traits>
namespace FRG {
namespace {
constexpr const char* kBattleZoneCampaignId = "battle_zone";
constexpr const char* kBattleZonePlayer2Id = "player2";
constexpr std::int64_t kBattleZoneTestBotIntervalMs = 5000;
void configurePlayerAnimations(
CharacterState& player, const std::string& zipFile)
{
player.creationInfo.animationPaths = {
{AnimationState::STAND,
"resources/w/gg/new2/gg_stand_idle001_small.anim"},
{AnimationState::WALK,
"resources/w/gg/new2/gg_walk001_small.anim"},
{AnimationState::STAND_TO_ACTION,
"resources/w/gg/new2/gg_stand_to_action001_small.anim"},
{AnimationState::ACTION_ATTACK,
"resources/w/gg/new2/gg_action_chop001_small.anim"},
{AnimationState::ACTION_ATTACK_2,
"resources/w/gg/new2/gg_action_stab001_small.anim"},
{AnimationState::ACTION_IDLE,
"resources/w/gg/new2/gg_action_idle002_small.anim"},
{AnimationState::ACTION_TO_STAND,
"resources/w/gg/new2/gg_action_to_stand001_small.anim"},
{AnimationState::ACTION_TO_DEATH,
"resources/w/gg/new2/gg_die001_small.anim"},
{AnimationState::DEATH_IDLE,
"resources/w/gg/new2/gg_die_idle001_small.anim"}};
loadCharacterAnimationMetadata(player, zipFile);
}
}
InternalGameServer::InternalGameServer() = default;
void InternalGameServer::ensureBattleZoneTestBot()
{
if (state_.campaignId != kBattleZoneCampaignId) return;
if (!battleZoneTestBotEnabled_) {
state_.players.erase(kBattleZonePlayer2Id);
return;
}
auto& bot = state_.players[kBattleZonePlayer2Id];
if (!bot) bot = std::make_shared<CharacterState>();
bot->configureAsPlayer();
configurePlayerAnimations(*bot, resourceZipFile_);
bot->npcName = "Test Bot";
}
void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs)
{
if (!battleZoneTestBotEnabled_ ||
state_.campaignId != kBattleZoneCampaignId ||
state_.currentLocationName.empty() ||
state_.players.find(kBattleZonePlayer2Id) == state_.players.end()) {
return;
}
battleZoneTestBotElapsedMs_ += deltaMs;
if (battleZoneTestBotElapsedMs_ < kBattleZoneTestBotIntervalMs) return;
battleZoneTestBotElapsedMs_ %= kBattleZoneTestBotIntervalMs;
static std::mt19937 randomEngine(std::random_device{}());
static std::uniform_real_distribution<float> coordinate(-10.0f, 10.0f);
submit(SetCharacterDestinationCommand{
state_.currentLocationName,
kBattleZonePlayer2Id,
Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)),
std::string()});
}
void InternalGameServer::initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile)
{
CampaignConfig campaign;
campaign.id = campaignId;
campaign.questsPath = questsPath;
startCampaign(campaign, zipFile, true, true);
}
void InternalGameServer::startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot)
{
resourceZipFile_ = zipFile;
networkPlayerSlots_ = campaign.networkPlayerSlots;
battleZoneTestBotEnabled_ = enableTestBot;
state_.campaignId = campaign.id;
state_.currentLocationName.clear();
state_.locations.clear();
state_.players.clear();
state_.questJournal.loadFromFile(campaign.questsPath, zipFile);
state_.globalFloats["player_hp"] = 200.f;
if (createLocalPlayer) {
auto player = std::make_shared<CharacterState>();
player->configureAsPlayer();
configurePlayerAnimations(*player, zipFile);
state_.players.emplace(localPlayerId_, std::move(player));
}
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
publishMutation();
}
void InternalGameServer::activateLocation(const std::string& locationId)
{
if (state_.locations.count(locationId) == 0)
throw std::runtime_error(
"Cannot activate unprepared location '" + locationId + "'");
state_.currentLocationName = locationId;
publishMutation();
}
void InternalGameServer::clearCampaign()
{
state_.currentLocationName.clear();
state_.players.clear();
state_.locations.clear();
networkPlayerSlots_.clear();
battleZoneTestBotElapsedMs_ = 0;
publishMutation();
}
void InternalGameServer::resetCampaign(
const std::string& questsPath, const std::string& zipFile)
{
pendingEvents_.clear();
state_.currentLocationName.clear();
state_.isDarklands = false;
state_.isNight = false;
state_.isDawn = false;
state_.playerHp = 200.f;
state_.playerMaxHp = 200.f;
state_.money = 5500;
state_.globalInts.clear();
state_.globalFloats.clear();
state_.globalFloats["player_hp"] = 200.f;
state_.tutorialStep = TutorialStep::AdvanceDialogue;
state_.tutorialPhoneChatScreenOpened = false;
state_.tutorialJournalScreenOpened = false;
state_.tutorialNeedOpenTaxiScreen = false;
state_.tutorialTaxiHintDone = false;
for (int i = 0; i < 3; ++i) {
state_.chatUnread[i] = true;
state_.chatPreviewMsg[i].clear();
state_.chatHistory[i].clear();
}
state_.inventory.clear();
state_.questJournal.loadFromFile(questsPath, zipFile);
state_.locations.clear();
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
publishMutation();
}
bool InternalGameServer::prepareLocation(const LocationConfig& config)
{
LocationGameState& authoritative = state_.locations[config.id];
const bool initializeLocation = authoritative.setup(
LocationContentLoader::load(config, resourceZipFile_),
resourceZipFile_);
auto player = state_.players.find(localPlayerId_);
if (player != state_.players.end() && player->second) {
player->second->position = config.playerPosition;
player->second->setTarget(
config.playerPosition, std::string(), *authoritative.location,
localPlayerId_);
}
publishMutation();
return initializeLocation;
}
GameSnapshot InternalGameServer::buildSnapshot() const
{
GameSnapshot result;
result.revision = revision_;
result.campaignId = state_.campaignId;
result.currentLocationId = state_.currentLocationName;
result.isDarklands = state_.isDarklands;
result.isNight = state_.isNight;
result.isDawn = state_.isDawn;
result.playerHp = state_.playerHp;
result.playerMaxHp = state_.playerMaxHp;
result.money = state_.money;
result.tutorialStep = state_.tutorialStep;
result.tutorialPhoneChatScreenOpened = state_.tutorialPhoneChatScreenOpened;
result.tutorialJournalScreenOpened = state_.tutorialJournalScreenOpened;
result.tutorialNeedOpenTaxiScreen = state_.tutorialNeedOpenTaxiScreen;
result.tutorialTaxiHintDone = state_.tutorialTaxiHintDone;
result.taxiIsCalled = state_.taxiIsCalled;
for (int i = 0; i < 3; ++i) {
result.chatUnread[i] = state_.chatUnread[i];
result.chatPreview[i] = state_.chatPreviewMsg[i];
result.chatHistory[i].reserve(state_.chatHistory[i].size());
for (const StoredChatMessage& message : state_.chatHistory[i])
result.chatHistory[i].push_back({message.text, message.incoming});
}
nlohmann::json inventoryJson;
state_.inventory.save(inventoryJson);
if (inventoryJson.contains("items") && inventoryJson["items"].is_array()) {
for (const auto& item : inventoryJson["items"])
if (item.is_string()) result.inventoryItemIds.push_back(item.get<std::string>());
}
state_.questJournal.save(result.questJournal);
result.globalInts = state_.globalInts;
result.globalFloats = state_.globalFloats;
for (const Quest::QuestState* quest : state_.questJournal.getVisibleQuests())
if (quest) result.quests.push_back(*quest);
for (const auto& [playerId, source] : state_.players) {
if (!source) continue;
CharacterState clean = *source;
result.players.emplace(playerId, CharacterSnapshot{playerId, std::move(clean)});
}
for (const auto& [locationId, source] : state_.locations) {
LocationSnapshot location;
location.id = locationId;
location.state = *source.location;
for (const auto& [id, sourceNpc] : source.npcs) {
if (!sourceNpc) continue;
CharacterState clean = *sourceNpc;
location.npcs.emplace(
id, CharacterSnapshot{id, std::move(clean)});
}
for (const auto& [id, sourceObject] : source.interactiveObjects) {
if (!sourceObject) continue;
InteractiveObjectState clean = *sourceObject;
location.interactiveObjects.emplace(
id, InteractiveObjectSnapshot{id, std::move(clean)});
}
location.triggerZones = source.triggerZones;
location.teleportZones = source.teleportZones;
location.dialogue = source.dialogue;
location.scriptGlobals = source.scriptGlobals;
result.locations.emplace(locationId, std::move(location));
}
return result;
}
std::shared_ptr<const GameSnapshot> InternalGameServer::snapshot() const
{
if (!cachedSnapshot_ || cachedRevision_ != revision_) {
cachedSnapshot_ = std::make_shared<const GameSnapshot>(buildSnapshot());
cachedRevision_ = revision_;
}
return cachedSnapshot_;
}
void InternalGameServer::submit(const GameCommand& command)
{
auto enqueueQuestEvents = [this](Quest::QuestMutationResult result) {
for (Quest::QuestEvent& event : result.events)
pendingEvents_.emplace_back(std::move(event));
};
std::visit([this, &enqueueQuestEvents](const auto& value) {
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, SetWorldModeCommand>) {
state_.isDarklands = value.darklands;
state_.isNight = value.night;
state_.isDawn = value.dawn;
} else if constexpr (std::is_same_v<T, SetCurrentLocationCommand>) {
state_.currentLocationName = value.locationId;
} else if constexpr (std::is_same_v<T, SetPlayerHealthCommand>) {
state_.playerHp = value.hp;
state_.playerMaxHp = value.maxHp;
} else if constexpr (std::is_same_v<T, SetMoneyCommand>) {
state_.money = value.amount;
} else if constexpr (std::is_same_v<T, SpendMoneyCommand>) {
state_.money -= value.amount;
} else if constexpr (std::is_same_v<T, CompleteTutorialStepCommand>) {
state_.completeTutorialStep(value.step);
} else if constexpr (std::is_same_v<T, SetTutorialStepCommand>) {
state_.tutorialStep = value.step;
} else if constexpr (std::is_same_v<T, SetTutorialFlagsCommand>) {
state_.tutorialPhoneChatScreenOpened = value.phoneChatOpened;
state_.tutorialJournalScreenOpened = value.journalOpened;
state_.tutorialNeedOpenTaxiScreen = value.needTaxiHint;
state_.tutorialTaxiHintDone = value.taxiHintDone;
} else if constexpr (std::is_same_v<T, SetChatCommand>) {
if (value.index >= 0 && value.index < 3) {
state_.chatUnread[value.index] = value.unread;
state_.chatPreviewMsg[value.index] = value.preview;
}
} else if constexpr (std::is_same_v<T, AppendChatMessageCommand>) {
if (value.index >= 0 && value.index < 3) {
if (state_.chatHistory[value.index].size() >= 5)
state_.chatHistory[value.index].erase(state_.chatHistory[value.index].begin());
state_.chatHistory[value.index].push_back({value.text, value.incoming});
}
} else if constexpr (std::is_same_v<T, SetTaxiCalledCommand>) {
state_.taxiIsCalled = value.called;
} else if constexpr (std::is_same_v<T, StartTaxiCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end())
location->second.startTaxi();
} else if constexpr (std::is_same_v<T, ResetTaxiCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end())
location->second.resetTaxi();
} else if constexpr (std::is_same_v<T, SetLocationTargetsCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end() && location->second.location) {
location->second.location->targetInteractiveObjectId =
value.interactiveObjectId;
location->second.location->targetInteractNpcId = value.npcId;
location->second.location->targetTeleportZoneId =
value.teleportZoneId;
}
} else if constexpr (std::is_same_v<T, SwitchLocationNavigationCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end() && location->second.location &&
value.index >= 0 &&
value.index < static_cast<int>(
location->second.location->navigationMaps.size())) {
LocationNetworkState& locationState = *location->second.location;
locationState.activeNavigationIndex = value.index;
for (const auto& [id, player] : state_.players)
if (player) player->forceReplan(locationState, id);
for (const auto& [id, npc] : location->second.npcs)
if (npc) npc->forceReplan(locationState, id);
}
} else if constexpr (std::is_same_v<T, SetNavigationAreaAvailableCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end() && location->second.location) {
LocationNetworkState& locationState = *location->second.location;
const int index = locationState.activeNavigationIndex;
if (index >= 0 &&
index < static_cast<int>(locationState.navigationMaps.size())) {
locationState.navigationMaps[index].setAreaAvailable(
value.areaName, value.available);
}
}
} else if constexpr (std::is_same_v<T, AddNavigationObstacleCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end() && location->second.location) {
LocationNetworkState& locationState = *location->second.location;
const int index = locationState.activeNavigationIndex;
if (index >= 0 &&
index < static_cast<int>(locationState.navigationMaps.size())) {
PathFinder::ObstaclePolygon polygon;
polygon.name = value.name;
polygon.polygon = value.polygon;
locationState.navigationMaps[index].addObstaclePolygon(polygon);
}
}
} else if constexpr (std::is_same_v<T, AddInventoryItemCommand>) {
if (state_.inventory.addItem(value.itemId))
pendingEvents_.emplace_back(InventoryItemAddedEvent{value.itemId});
} else if constexpr (std::is_same_v<T, RemoveInventoryItemCommand>) {
if (state_.inventory.removeItem(value.itemId))
pendingEvents_.emplace_back(InventoryItemRemovedEvent{value.itemId});
} else if constexpr (std::is_same_v<T, UnlockQuestCommand>) {
enqueueQuestEvents(state_.questJournal.unlockQuest(value.questId));
} else if constexpr (std::is_same_v<T, CompleteQuestCommand>) {
enqueueQuestEvents(state_.questJournal.completeQuest(value.questId));
} else if constexpr (std::is_same_v<T, FailQuestCommand>) {
enqueueQuestEvents(state_.questJournal.failQuest(value.questId));
} else if constexpr (std::is_same_v<T, SetQuestObjectiveCompletedCommand>) {
enqueueQuestEvents(state_.questJournal.setObjectiveCompleted(
value.questId, value.objectiveId, value.completed));
} else if constexpr (std::is_same_v<T, SetQuestObjectiveVisibleCommand>) {
enqueueQuestEvents(state_.questJournal.setObjectiveVisible(
value.questId, value.objectiveId, value.visible));
} else if constexpr (std::is_same_v<T, SetQuestActiveObjectiveCommand>) {
enqueueQuestEvents(state_.questJournal.setActiveObjective(
value.questId, value.objectiveIndex));
} else if constexpr (std::is_same_v<T, SetGlobalIntCommand>) {
state_.globalInts[value.key] = value.value;
} else if constexpr (std::is_same_v<T, SetGlobalFloatCommand>) {
state_.globalFloats[value.key] = value.value;
} else if constexpr (std::is_same_v<T, SetCharacterTransformCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) {
character->position = value.position;
character->facingAngle = value.facingAngle;
character->targetFacingAngle = value.facingAngle;
}
} else if constexpr (std::is_same_v<T, SetCharacterTargetCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->attackTargetId = value.targetId;
} else if constexpr (std::is_same_v<T, ClearCharacterAttackTargetCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->attackTargetId.clear();
} else if constexpr (std::is_same_v<T, SetCharacterDestinationCommand>) {
auto location = state_.locations.find(value.locationId);
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character && location != state_.locations.end() &&
location->second.location) {
character->setTarget(
value.target, value.callbackName,
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, StopCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->stopInPlace();
} else if constexpr (std::is_same_v<T, FaceCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character)
character->stopAndRotateToTarget(value.targetId);
} else if constexpr (std::is_same_v<T, ReplanCharacterCommand>) {
auto location = state_.locations.find(value.locationId);
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character && location != state_.locations.end() &&
location->second.location) {
character->forceReplan(
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, ResetCharacterAfterDeathCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->resetPlayerAfterDeath();
} else if constexpr (std::is_same_v<T, TryCharacterAttackComboCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->tryAttackCombo();
} else if constexpr (std::is_same_v<T, SetCharacterHealthCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->setHp(value.hp);
} else if constexpr (std::is_same_v<T, SetCharacterAnimationMetadataCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) {
character->setAnimationMetadata(
value.animationState, value.startFrame, value.frameCount);
}
} else if constexpr (std::is_same_v<T, DamageCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->hp = (std::max)(0.f, character->hp - (std::max)(0.f, value.amount));
} else if constexpr (std::is_same_v<T, CommitCharacterStateCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) *character = value.state;
} else if constexpr (std::is_same_v<T, SetInteractiveObjectActiveCommand>) {
auto location = state_.locations.find(value.locationId);
if (location == state_.locations.end()) return;
auto object = location->second.interactiveObjects.find(value.objectId);
if (object != location->second.interactiveObjects.end() && object->second)
object->second->isActive = value.active;
} else if constexpr (std::is_same_v<T, SetTriggerZoneEnabledCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end()) {
location->second.triggerZones[value.zoneId].enabled = value.enabled;
pendingTriggerZoneUpdates_.emplace_back(
value.locationId, value.zoneId);
}
} else if constexpr (std::is_same_v<T, ReloadQuestDefinitionsCommand>) {
state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile);
}
}, command);
publishMutation();
}
std::vector<GameServerEvent> InternalGameServer::drainEvents()
{
std::vector<GameServerEvent> result;
result.swap(pendingEvents_);
return result;
}
void InternalGameServer::update(std::int64_t deltaMs)
{
pendingSimulationLocationId_.clear();
pendingSimulationEvents_ = {};
auto stored = state_.locations.find(state_.currentLocationName);
if (stored == state_.locations.end()) return;
updateBattleZoneTestBot(deltaMs);
const bool interactiveObjectsLocked =
stored->second.lockInteractiveObjectsDuringTutorial &&
state_.tutorialStep < TutorialStep::PickUpItem;
pendingSimulationLocationId_ = state_.currentLocationName;
pendingSimulationEvents_ =
stored->second.update(deltaMs, state_.players, interactiveObjectsLocked);
// Navigation recovery is a simulation consequence, not presentation input.
// Applying it here makes every lockstep replica produce the same follow-up.
for (const CharacterSimulationEvent& event :
pendingSimulationEvents_.characters) {
if (!event.events.navigationRequested) continue;
CharacterState* character = state_.findCharacter(
state_.currentLocationName, event.characterId);
if (character && stored->second.location) {
character->setTarget(
event.events.navigationTarget, {},
*stored->second.location, event.characterId);
}
}
publishMutation();
}
void InternalGameServer::save(nlohmann::json& out) const
{
state_.save(out);
}
void InternalGameServer::load(const nlohmann::json& in)
{
pendingEvents_.clear();
state_.load(in);
for (const std::string& playerId : networkPlayerSlots_) {
auto player = state_.players.find(playerId);
if (player == state_.players.end() || !player->second) continue;
player->second->configureAsPlayer();
configurePlayerAnimations(*player->second, resourceZipFile_);
}
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
publishMutation();
}
void InternalGameServer::publishMutation()
{
++revision_;
cachedSnapshot_.reset();
}
} // namespace FRG
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "IGameServer.h"
#include "IGameServerLifecycle.h"
namespace FRG {
class LocalGameServerHost;
class RemoteGameServerHost;
struct LocationConfig;
// In-process implementation used by single-player. GameState exists exactly
// once, here; clients see immutable snapshots and mutate it with typed commands.
class InternalGameServer final
: public IGameServer, public IGameServerLifecycle {
public:
InternalGameServer();
std::shared_ptr<const GameSnapshot> snapshot() const override;
const std::string& localPlayerId() const override { return localPlayerId_; }
void submit(const GameCommand& command) override;
std::vector<GameServerEvent> drainEvents() override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
void setLocalPlayerId(std::string playerId) {
localPlayerId_ = std::move(playerId);
}
void startCampaign(
const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot) override;
bool prepareLocation(const LocationConfig& config) override;
void activateLocation(const std::string& locationId) override;
private:
friend class LocalGameServerHost;
friend class RemoteGameServerHost;
friend class WebsocketGameClient;
// Host-only lifecycle operations. They never expose authoritative state and
// are intentionally absent from IGameServer.
void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile);
void clearCampaign();
void resetCampaign(const std::string& questsPath, const std::string& zipFile);
void publishMutation();
void ensureBattleZoneTestBot();
void updateBattleZoneTestBot(std::int64_t deltaMs);
GameSnapshot buildSnapshot() const;
GameState state_;
std::string localPlayerId_ = "player";
std::string resourceZipFile_;
std::vector<std::string> networkPlayerSlots_;
std::int64_t battleZoneTestBotElapsedMs_ = 0;
bool battleZoneTestBotEnabled_ = false;
std::string pendingSimulationLocationId_;
LocationSimulationEvents pendingSimulationEvents_;
std::vector<std::pair<std::string, std::string>> pendingTriggerZoneUpdates_;
std::uint64_t revision_ = 1;
mutable std::uint64_t cachedRevision_ = 0;
mutable std::shared_ptr<const GameSnapshot> cachedSnapshot_;
std::vector<GameServerEvent> pendingEvents_;
};
} // namespace FRG
+164
View File
@@ -0,0 +1,164 @@
#include "LocalGameServerHost.h"
#include "InternalGameServer.h"
#include "../Campaign.h"
#include "../Location.h"
#include <stdexcept>
namespace FRG {
LocalGameServerHost::LocalGameServerHost(InternalGameServer& server)
: server_(server)
{
}
void LocalGameServerHost::initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile)
{
localState_.clear();
server_.initializeCampaign(campaignId, questsPath, zipFile);
}
void LocalGameServerHost::clearCampaign()
{
runtimeLocations_.clear();
localState_.clear();
server_.clearCampaign();
}
void LocalGameServerHost::resetCampaign(
const std::string& questsPath, const std::string& zipFile)
{
localState_.clear();
server_.resetCampaign(questsPath, zipFile);
}
bool LocalGameServerHost::prepareLocation(const LocationConfig& config)
{
return server_.prepareLocation(config);
}
void LocalGameServerHost::setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer)
{
auto stored = server_.state_.locations.find(id);
if (stored == server_.state_.locations.end())
throw std::runtime_error("Location state was not prepared");
params.definition = stored->second.definition;
params.gameState = &stored->second;
params.localState = localState_.getOrCreate(id);
location->setup(params, audioPlayer);
server_.publishMutation();
}
Location* LocalGameServerHost::runtimeLocation(const std::string& id) const
{
auto it = runtimeLocations_.find(id);
return it != runtimeLocations_.end() ? it->second.get() : nullptr;
}
bool LocalGameServerHost::hasRuntimeLocation(const std::string& id) const
{
return runtimeLocations_.count(id) > 0;
}
std::vector<std::string> LocalGameServerHost::loadedRuntimeLocationIds() const
{
std::vector<std::string> result;
result.reserve(runtimeLocations_.size());
for (const auto& [id, location] : runtimeLocations_) result.push_back(id);
return result;
}
void LocalGameServerHost::clearRuntimeLocations(bool retainState)
{
if (retainState) {
synchronizeRuntimeAuxiliaryState();
} else {
for (const auto& [id, location] : runtimeLocations_)
server_.state_.locations.erase(id);
server_.publishMutation();
}
runtimeLocations_.clear();
}
void LocalGameServerHost::bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = server_.state_.locations.find(id);
if (stored != server_.state_.locations.end() &&
stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
} else {
runtimeLocations_[id]->captureAuxiliaryState(server_.state_.locations[id]);
}
server_.publishMutation();
}
void LocalGameServerHost::update(std::int64_t deltaMs)
{
server_.update(deltaMs);
for (const auto& [locationId, zoneId] : server_.pendingTriggerZoneUpdates_) {
auto runtime = runtimeLocations_.find(locationId);
auto stored = server_.state_.locations.find(locationId);
if (runtime == runtimeLocations_.end() || !runtime->second ||
stored == server_.state_.locations.end()) {
continue;
}
auto zoneState = stored->second.triggerZones.find(zoneId);
if (zoneState == stored->second.triggerZones.end()) continue;
for (auto& zone : runtime->second->triggerZones)
if (zone.id == zoneId) zone.enabled = zoneState->second.enabled;
}
server_.pendingTriggerZoneUpdates_.clear();
if (server_.pendingSimulationLocationId_.empty()) return;
auto runtime =
runtimeLocations_.find(server_.pendingSimulationLocationId_);
if (runtime == runtimeLocations_.end() || !runtime->second) return;
runtime->second->update(deltaMs, server_.pendingSimulationEvents_);
synchronizeRuntimeAuxiliaryState();
}
void LocalGameServerHost::save(nlohmann::json& out)
{
synchronizeRuntimeAuxiliaryState();
nlohmann::json networkState;
server_.save(networkState);
saveSinglePlayerPersistence(networkState, localState_, out);
}
void LocalGameServerHost::load(const nlohmann::json& in)
{
nlohmann::json networkState;
ClientLocationState loadedLocalState;
loadSinglePlayerPersistence(in, networkState, loadedLocalState);
server_.load(networkState);
nlohmann::json localStateJson;
loadedLocalState.save(localStateJson);
localState_.load(localStateJson);
applyStateToRuntime();
}
void LocalGameServerHost::synchronizeRuntimeAuxiliaryState()
{
for (const auto& [id, location] : runtimeLocations_) {
if (!location) continue;
location->captureAuxiliaryState(server_.state_.locations[id]);
}
server_.publishMutation();
}
void LocalGameServerHost::applyStateToRuntime()
{
for (const auto& [id, stored] : server_.state_.locations) {
auto runtime = runtimeLocations_.find(id);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->applyAuxiliaryState(stored);
}
}
} // namespace FRG
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "IGameServerHost.h"
#include "../LocationState.h"
#include <unordered_map>
namespace FRG {
class InternalGameServer;
// Adapts the headless authoritative server to the graphical in-process client.
class LocalGameServerHost final : public IGameServerHost {
public:
explicit LocalGameServerHost(InternalGameServer& server);
void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) override;
void clearCampaign() override;
void resetCampaign(
const std::string& questsPath, const std::string& zipFile) override;
bool prepareLocation(const LocationConfig& config) override;
void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) override;
Location* runtimeLocation(const std::string& id) const override;
bool hasRuntimeLocation(const std::string& id) const override;
std::vector<std::string> loadedRuntimeLocationIds() const override;
void clearRuntimeLocations(bool retainState) override;
void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) override;
void load(const nlohmann::json& in) override;
private:
void synchronizeRuntimeAuxiliaryState();
void applyStateToRuntime();
InternalGameServer& server_;
ClientLocationState localState_;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
};
} // namespace FRG
+268
View File
@@ -0,0 +1,268 @@
#include "NetworkProtocol.h"
#include <cmath>
#include <exception>
#include <iomanip>
#include <limits>
#include <sstream>
namespace FRG {
namespace {
bool parseEnvelope(
const std::string& text, const char* expectedType,
nlohmann::json& payload, std::string& error)
{
try {
const nlohmann::json envelope = nlohmann::json::parse(text);
if (!envelope.is_object()) {
error = "message must be a JSON object";
return false;
}
if (envelope.value("protocolVersion", -1) != kGameProtocolVersion) {
error = "unsupported protocol version";
return false;
}
if (envelope.value("type", std::string()) != expectedType) {
error = "unsupported message type";
return false;
}
if (!envelope.contains("payload") || !envelope["payload"].is_object()) {
error = "message payload must be an object";
return false;
}
payload = envelope["payload"];
error.clear();
return true;
} catch (const std::exception& exception) {
error = exception.what();
return false;
}
}
std::string envelope(const char* type, nlohmann::json payload)
{
return nlohmann::json{
{"protocolVersion", kGameProtocolVersion},
{"type", type},
{"payload", std::move(payload)}
}.dump();
}
bool readTick(
const nlohmann::json& value, const char* name,
std::uint64_t& out, std::string& error)
{
if (!value.contains(name) ||
(!value[name].is_number_unsigned() && !value[name].is_number_integer())) {
error = std::string(name) + " must be a non-negative integer";
return false;
}
const auto signedValue = value[name].get<std::int64_t>();
if (signedValue < 0) {
error = std::string(name) + " must be non-negative";
return false;
}
out = static_cast<std::uint64_t>(signedValue);
return true;
}
bool readTarget(
const nlohmann::json& value, Eigen::Vector3f& target, std::string& error)
{
if (!value.is_array() || value.size() != 3) {
error = "target must contain three coordinates";
return false;
}
for (std::size_t index = 0; index < 3; ++index) {
if (!value[index].is_number()) {
error = "target coordinates must be numbers";
return false;
}
const float coordinate = value[index].get<float>();
if (!std::isfinite(coordinate) || std::abs(coordinate) > 100000.f) {
error = "target coordinate is not finite or is out of range";
return false;
}
target[static_cast<Eigen::Index>(index)] =
std::round(coordinate * 100.f) / 100.f;
}
return true;
}
nlohmann::json targetJson(const Eigen::Vector3f& target)
{
return nlohmann::json::array({target.x(), target.y(), target.z()});
}
} // namespace
std::string makeInitialStateMessage(const InitialStateMessage& message)
{
return envelope("initial_state", {
{"state", message.state},
{"assignedPlayerId", message.assignedPlayerId},
{"snapshotTick", message.snapshotTick},
{"tickMs", message.tickMs},
{"inputDelayTicks", message.inputDelayTicks},
{"compatibilityFingerprint", message.compatibilityFingerprint}
});
}
bool parseInitialStateMessage(
const std::string& text, InitialStateMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "initial_state", payload, error)) return false;
if (!payload.contains("state") || !payload["state"].is_object() ||
!payload.contains("assignedPlayerId") ||
!payload["assignedPlayerId"].is_string() ||
!payload.contains("compatibilityFingerprint") ||
!payload["compatibilityFingerprint"].is_string()) {
error = "invalid initial-state payload";
return false;
}
if (!readTick(payload, "snapshotTick", message.snapshotTick, error))
return false;
message.tickMs = payload.value("tickMs", std::int64_t{0});
message.inputDelayTicks =
payload.value("inputDelayTicks", std::uint32_t{0});
if (message.tickMs != kSimulationTickMs || message.inputDelayTicks == 0) {
error = "unsupported lockstep timing";
return false;
}
message.state = payload["state"];
message.assignedPlayerId = payload["assignedPlayerId"].get<std::string>();
message.compatibilityFingerprint =
payload["compatibilityFingerprint"].get<std::string>();
return !message.assignedPlayerId.empty();
}
std::uint64_t LockstepTimeline::schedule(
const std::string& playerId, const Eigen::Vector3f& target)
{
const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back(
{sequence, playerId, target});
return sequence;
}
TickMessage LockstepTimeline::advance()
{
TickMessage result;
result.tickId = ++currentTick_;
auto commands = pending_.find(result.tickId);
if (commands != pending_.end()) {
result.commands = std::move(commands->second);
pending_.erase(commands);
}
return result;
}
std::string makeDestinationInputMessage(const DestinationInputMessage& message)
{
return envelope("destination_input", {
{"clientSequence", message.clientSequence},
{"target", targetJson(message.target)}
});
}
bool parseDestinationInputMessage(
const std::string& text, DestinationInputMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "destination_input", payload, error)) return false;
if (!readTick(payload, "clientSequence", message.clientSequence, error))
return false;
if (!payload.contains("target"))
return error = "target is required", false;
return readTarget(payload["target"], message.target, error);
}
std::string makeTickMessage(const TickMessage& message)
{
nlohmann::json commands = nlohmann::json::array();
for (const ScheduledDestinationCommand& command : message.commands) {
commands.push_back({
{"commandSequence", command.commandSequence},
{"playerId", command.playerId},
{"target", targetJson(command.target)}
});
}
return envelope("tick", {
{"tickId", message.tickId},
{"commands", std::move(commands)},
{"stateHash", message.stateHash}
});
}
bool parseTickMessage(
const std::string& text, TickMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "tick", payload, error)) return false;
if (!readTick(payload, "tickId", message.tickId, error)) return false;
if (!payload.contains("commands") || !payload["commands"].is_array()) {
error = "commands must be an array";
return false;
}
message.commands.clear();
std::uint64_t previousSequence = 0;
for (const nlohmann::json& value : payload["commands"]) {
ScheduledDestinationCommand command;
if (!value.is_object() ||
!readTick(value, "commandSequence", command.commandSequence, error) ||
!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("target") ||
!readTarget(value["target"], command.target, error)) {
if (error.empty()) error = "invalid scheduled command";
return false;
}
command.playerId = value["playerId"].get<std::string>();
if (command.playerId.empty() || command.commandSequence == 0 ||
command.commandSequence <= previousSequence) {
error = "commands must have unique increasing sequences";
return false;
}
previousSequence = command.commandSequence;
message.commands.push_back(std::move(command));
}
message.stateHash = payload.value("stateHash", std::string());
return true;
}
std::string makeErrorMessage(const std::string& code, const std::string& detail)
{
return envelope("error", {{"code", code}, {"detail", detail}});
}
bool parseErrorMessage(
const std::string& text, std::string& code, std::string& detail)
{
nlohmann::json payload;
std::string error;
if (!parseEnvelope(text, "error", payload, error) ||
!payload.contains("code") || !payload["code"].is_string() ||
!payload.contains("detail") || !payload["detail"].is_string()) {
return false;
}
code = payload["code"].get<std::string>();
detail = payload["detail"].get<std::string>();
return true;
}
std::string canonicalStateHash(nlohmann::json state)
{
state.erase("savedAt");
const std::string bytes = state.dump();
std::uint64_t hash = 1469598103934665603ull;
for (const unsigned char value : bytes) {
hash ^= value;
hash *= 1099511628211ull;
}
std::ostringstream output;
output << std::hex << std::setfill('0') << std::setw(16) << hash;
return output.str();
}
} // namespace FRG
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include "../external/nlohmann/json.hpp"
#include <Eigen/Dense>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace FRG {
inline constexpr int kGameProtocolVersion = 2;
inline constexpr std::int64_t kSimulationTickMs = 100;
inline constexpr std::uint32_t kDefaultInputDelayTicks = 2;
struct InitialStateMessage {
nlohmann::json state;
std::string assignedPlayerId;
std::uint64_t snapshotTick = 0;
std::int64_t tickMs = kSimulationTickMs;
std::uint32_t inputDelayTicks = kDefaultInputDelayTicks;
std::string compatibilityFingerprint;
};
struct DestinationInputMessage {
std::uint64_t clientSequence = 0;
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct ScheduledDestinationCommand {
std::uint64_t commandSequence = 0;
std::string playerId;
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct TickMessage {
std::uint64_t tickId = 0;
std::vector<ScheduledDestinationCommand> commands;
std::string stateHash;
};
class LockstepTimeline {
public:
explicit LockstepTimeline(
std::uint32_t inputDelayTicks = kDefaultInputDelayTicks)
: inputDelayTicks_(inputDelayTicks) {}
std::uint64_t currentTick() const { return currentTick_; }
std::uint64_t schedule(
const std::string& playerId, const Eigen::Vector3f& target);
TickMessage advance();
private:
std::uint32_t inputDelayTicks_;
std::uint64_t currentTick_ = 0;
std::uint64_t nextCommandSequence_ = 1;
std::map<std::uint64_t, std::vector<ScheduledDestinationCommand>> pending_;
};
std::string makeInitialStateMessage(const InitialStateMessage& message);
bool parseInitialStateMessage(
const std::string& text, InitialStateMessage& message, std::string& error);
std::string makeDestinationInputMessage(const DestinationInputMessage& message);
bool parseDestinationInputMessage(
const std::string& text, DestinationInputMessage& message, std::string& error);
std::string makeTickMessage(const TickMessage& message);
bool parseTickMessage(
const std::string& text, TickMessage& message, std::string& error);
std::string makeErrorMessage(const std::string& code, const std::string& detail);
bool parseErrorMessage(
const std::string& text, std::string& code, std::string& detail);
// Stable for the same executable/content build. Persistence-only wall-clock
// fields are removed before hashing.
std::string canonicalStateHash(nlohmann::json state);
} // namespace FRG
+133
View File
@@ -0,0 +1,133 @@
#include "RemoteGameServerHost.h"
#include "WebsocketGameClient.h"
#include "../Campaign.h"
#include "../Location.h"
#include <stdexcept>
namespace FRG {
RemoteGameServerHost::RemoteGameServerHost(WebsocketGameClient& client)
: client_(client)
{
}
void RemoteGameServerHost::initializeCampaign(
const std::string& campaignId, const std::string&,
const std::string& zipFile)
{
localState_.clear();
const CampaignConfig* campaign =
CampaignRegistry::instance().find(campaignId);
if (!campaign)
throw std::runtime_error("Unknown remote campaign '" + campaignId + "'");
client_.expectCompatibility(
campaign->networkCompatibility.empty()
? "lockstep-v2:" + campaignId
: campaign->networkCompatibility);
client_.replica_.startCampaign(*campaign, zipFile, true, false);
}
void RemoteGameServerHost::clearCampaign()
{
runtimeLocations_.clear();
localState_.clear();
client_.replica_.clearCampaign();
}
void RemoteGameServerHost::resetCampaign(
const std::string&, const std::string&)
{
// The authoritative remote campaign cannot be reset by this client.
}
bool RemoteGameServerHost::prepareLocation(const LocationConfig& config)
{
auto existing = client_.replica_.state_.locations.find(config.id);
if (existing != client_.replica_.state_.locations.end() &&
existing->second.definition) {
// The location was prepared before initial_state. Loading the
// authoritative snapshot overlays mutable state while retaining this
// definition; preparing it again would reset the assigned player's
// position to LocationConfig::playerPosition.
return false;
}
return client_.replica_.prepareLocation(config);
}
void RemoteGameServerHost::setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer)
{
auto stored = client_.replica_.state_.locations.find(id);
if (stored == client_.replica_.state_.locations.end())
throw std::runtime_error("Remote location state was not prepared");
params.definition = stored->second.definition;
params.gameState = &stored->second;
params.localState = localState_.getOrCreate(id);
location->setup(params, audioPlayer);
}
Location* RemoteGameServerHost::runtimeLocation(const std::string& id) const
{
auto found = runtimeLocations_.find(id);
return found == runtimeLocations_.end() ? nullptr : found->second.get();
}
bool RemoteGameServerHost::hasRuntimeLocation(const std::string& id) const
{
return runtimeLocations_.count(id) > 0;
}
std::vector<std::string> RemoteGameServerHost::loadedRuntimeLocationIds() const
{
std::vector<std::string> result;
result.reserve(runtimeLocations_.size());
for (const auto& [id, location] : runtimeLocations_) result.push_back(id);
return result;
}
void RemoteGameServerHost::clearRuntimeLocations(bool)
{
runtimeLocations_.clear();
}
void RemoteGameServerHost::bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = client_.replica_.state_.locations.find(id);
if (stored != client_.replica_.state_.locations.end() &&
stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
}
}
void RemoteGameServerHost::update(std::int64_t deltaMs)
{
client_.update(deltaMs);
for (auto& completed : client_.takeCompletedSimulationTicks()) {
if (completed.locationId.empty()) continue;
auto runtime = runtimeLocations_.find(completed.locationId);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->update(kSimulationTickMs, completed.events);
}
}
void RemoteGameServerHost::save(nlohmann::json&)
{
// Persistence remains server-side.
}
void RemoteGameServerHost::load(const nlohmann::json& in)
{
// The authoritative half is loaded by the standalone server. Each client
// restores only its local presentation state from the shared start state.
nlohmann::json ignoredNetworkState;
ClientLocationState loadedLocalState;
loadSinglePlayerPersistence(in, ignoredNetworkState, loadedLocalState);
nlohmann::json localStateJson;
loadedLocalState.save(localStateJson);
localState_.load(localStateJson);
}
} // namespace FRG
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "IGameServerHost.h"
#include "../LocationState.h"
#include <unordered_map>
namespace FRG {
class WebsocketGameClient;
class RemoteGameServerHost final : public IGameServerHost {
public:
explicit RemoteGameServerHost(WebsocketGameClient& client);
void initializeCampaign(
const std::string& campaignId, const std::string& questsPath,
const std::string& zipFile) override;
void clearCampaign() override;
void resetCampaign(
const std::string& questsPath, const std::string& zipFile) override;
bool prepareLocation(const LocationConfig& config) override;
void setupRuntimeLocation(
const std::string& id, const std::shared_ptr<Location>& location,
LocationSetup params, AudioPlayerAsync* audioPlayer) override;
Location* runtimeLocation(const std::string& id) const override;
bool hasRuntimeLocation(const std::string& id) const override;
std::vector<std::string> loadedRuntimeLocationIds() const override;
void clearRuntimeLocations(bool retainState) override;
void bindRuntimeLocation(
const std::string& id, std::shared_ptr<Location> location) override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) override;
void load(const nlohmann::json& in) override;
private:
WebsocketGameClient& client_;
ClientLocationState localState_;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
};
} // namespace FRG
+310
View File
@@ -0,0 +1,310 @@
#include "WebsocketGameClient.h"
#include "NetworkProtocol.h"
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <atomic>
#include <deque>
#include <iostream>
#include <mutex>
#include <thread>
#include <utility>
#include <vector>
namespace FRG {
namespace {
namespace asio = boost::asio;
namespace beast = boost::beast;
namespace websocket = beast::websocket;
using tcp = asio::ip::tcp;
} // namespace
class WebsocketGameClient::Impl {
public:
Impl(std::string host, std::string port, std::string path)
: resolver_(io_)
, socket_(io_)
, host_(std::move(host))
, port_(std::move(port))
, path_(std::move(path))
{
std::cout << "[network] Resolving ws://" << host_ << ':' << port_
<< path_ << std::endl;
resolve();
thread_ = std::thread([this]() { io_.run(); });
}
~Impl()
{
io_.stop();
if (thread_.joinable()) thread_.join();
}
std::vector<std::string> takeMessages()
{
std::lock_guard<std::mutex> lock(messagesMutex_);
std::vector<std::string> result;
result.swap(messages_);
return result;
}
bool connected() const { return connected_.load(); }
bool connectionFailed() const { return connectionFailed_.load(); }
void send(std::string message)
{
asio::post(io_, [this, message = std::move(message)]() mutable {
if (!connected_.load()) return;
const bool writeInProgress = !outbound_.empty();
outbound_.push_back(std::move(message));
if (!writeInProgress) write();
});
}
private:
void resolve()
{
resolver_.async_resolve(
host_, port_,
[this](beast::error_code error, tcp::resolver::results_type results) {
if (error) return fail("resolve", error);
std::cout << "[network] WebSocket host resolved; connecting"
<< std::endl;
asio::async_connect(
socket_.next_layer(), results,
[this](beast::error_code connectError,
const tcp::endpoint&) {
if (connectError) return fail("connect", connectError);
std::cout << "[network] TCP connected; starting WebSocket handshake"
<< std::endl;
socket_.set_option(
websocket::stream_base::timeout::suggested(
beast::role_type::client));
socket_.async_handshake(
host_, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to ws://"
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
});
}
void read()
{
socket_.async_read(
buffer_,
[this](beast::error_code error, std::size_t) {
if (error) {
connected_.store(false);
if (error != websocket::error::closed) fail("read", error);
return;
}
std::string message = beast::buffers_to_string(buffer_.data());
buffer_.consume(buffer_.size());
{
std::lock_guard<std::mutex> lock(messagesMutex_);
messages_.push_back(std::move(message));
}
read();
});
}
void write()
{
socket_.async_write(
asio::buffer(outbound_.front()),
[this](beast::error_code error, std::size_t) {
if (error) return fail("write", error);
outbound_.pop_front();
if (!outbound_.empty()) write();
});
}
void fail(const char* operation, beast::error_code error)
{
connected_.store(false);
connectionFailed_.store(true);
std::cerr << "[network] WebSocket " << operation << " failed: "
<< error.message() << '\n';
}
asio::io_context io_;
tcp::resolver resolver_;
websocket::stream<tcp::socket> socket_;
beast::flat_buffer buffer_;
std::thread thread_;
std::string host_;
std::string port_;
std::string path_;
std::atomic_bool connected_{false};
std::atomic_bool connectionFailed_{false};
std::mutex messagesMutex_;
std::vector<std::string> messages_;
std::deque<std::string> outbound_;
};
WebsocketGameClient::WebsocketGameClient(
std::string host, std::string port, std::string path)
: impl_(std::make_unique<Impl>(
std::move(host), std::move(port), std::move(path)))
{
}
WebsocketGameClient::~WebsocketGameClient() = default;
std::shared_ptr<const GameSnapshot> WebsocketGameClient::snapshot() const
{
return replica_.snapshot();
}
const std::string& WebsocketGameClient::localPlayerId() const
{
return replica_.localPlayerId();
}
void WebsocketGameClient::submit(const GameCommand& command)
{
if (!initialized_) return;
if (const auto* destination =
std::get_if<SetCharacterDestinationCommand>(&command)) {
impl_->send(makeDestinationInputMessage(
{nextClientSequence_++, destination->target}));
}
}
std::vector<GameServerEvent> WebsocketGameClient::drainEvents()
{
std::vector<GameServerEvent> result;
result.swap(pendingEvents_);
return result;
}
void WebsocketGameClient::update(std::int64_t)
{
std::vector<std::string> messages = impl_->takeMessages();
for (const std::string& text : messages) {
std::string error;
InitialStateMessage initial;
if (parseInitialStateMessage(text, initial, error)) {
if (!expectedCompatibilityFingerprint_.empty() &&
initial.compatibilityFingerprint !=
expectedCompatibilityFingerprint_) {
std::cerr << "[network] Incompatible server content: expected "
<< expectedCompatibilityFingerprint_ << ", received "
<< initial.compatibilityFingerprint << '\n';
continue;
}
try {
replica_.load(initial.state);
replica_.setLocalPlayerId(initial.assignedPlayerId);
nextExpectedTick_ = initial.snapshotTick + 1;
compatibilityFingerprint_ =
std::move(initial.compatibilityFingerprint);
pendingTicks_.clear();
initialized_ = true;
std::cout << "[network] Applied initial state at tick "
<< initial.snapshotTick << " as player '"
<< initial.assignedPlayerId << "' (delay "
<< initial.inputDelayTicks << " ticks)" << std::endl;
} catch (const std::exception& exception) {
std::cerr << "[network] Invalid initial state: "
<< exception.what() << '\n';
}
continue;
}
TickMessage tick;
if (parseTickMessage(text, tick, error)) {
if (initialized_ && tick.tickId >= nextExpectedTick_) {
if (!loggedFirstTick_) {
std::cout << "[network] Received first lockstep tick "
<< tick.tickId << std::endl;
loggedFirstTick_ = true;
}
pendingTicks_.emplace(tick.tickId, std::move(tick));
}
continue;
}
std::string errorCode;
std::string errorDetail;
if (parseErrorMessage(text, errorCode, errorDetail)) {
std::cerr << "[network] Server rejected request (" << errorCode
<< "): " << errorDetail << '\n';
if (errorCode == "server_full" ||
errorCode == "incompatible")
rejected_ = true;
continue;
}
std::cerr << "[network] Ignoring WebSocket message: "
<< error << '\n';
}
while (initialized_) {
auto next = pendingTicks_.find(nextExpectedTick_);
if (next == pendingTicks_.end()) break;
TickMessage tick = std::move(next->second);
pendingTicks_.erase(next);
const std::string locationId =
replica_.snapshot()->currentLocationId;
for (const ScheduledDestinationCommand& command : tick.commands) {
replica_.submit(SetCharacterDestinationCommand{
locationId, command.playerId, command.target, {}});
}
replica_.update(kSimulationTickMs);
completedSimulationTicks_.push_back({
replica_.pendingSimulationLocationId_,
replica_.pendingSimulationEvents_});
std::vector<GameServerEvent> events = replica_.drainEvents();
pendingEvents_.insert(
pendingEvents_.end(),
std::make_move_iterator(events.begin()),
std::make_move_iterator(events.end()));
if (!tick.stateHash.empty()) {
nlohmann::json state;
replica_.save(state);
const std::string localHash = canonicalStateHash(std::move(state));
if (localHash != tick.stateHash) {
std::cerr << "[network] Lockstep divergence at tick "
<< tick.tickId << ": server=" << tick.stateHash
<< ", client=" << localHash << '\n';
}
}
++nextExpectedTick_;
}
}
std::vector<WebsocketGameClient::CompletedSimulationTick>
WebsocketGameClient::takeCompletedSimulationTicks()
{
std::vector<CompletedSimulationTick> result;
result.swap(completedSimulationTicks_);
return result;
}
void WebsocketGameClient::save(nlohmann::json&) const
{
// Persistence remains server-side.
}
void WebsocketGameClient::load(const nlohmann::json&)
{
// Persistence remains server-side.
}
bool WebsocketGameClient::connected() const
{
return impl_->connected();
}
bool WebsocketGameClient::connectionFailed() const
{
return impl_->connectionFailed();
}
} // namespace FRG
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "IGameServer.h"
#include "InternalGameServer.h"
#include "NetworkProtocol.h"
#include <map>
#include <memory>
#include <string>
namespace FRG {
class RemoteGameServerHost;
class WebsocketGameClient final : public IGameServer {
public:
WebsocketGameClient(
std::string host = "127.0.0.1",
std::string port = "8080",
std::string path = "/");
~WebsocketGameClient();
WebsocketGameClient(const WebsocketGameClient&) = delete;
WebsocketGameClient& operator=(const WebsocketGameClient&) = delete;
std::shared_ptr<const GameSnapshot> snapshot() const override;
const std::string& localPlayerId() const override;
void submit(const GameCommand& command) override;
std::vector<GameServerEvent> drainEvents() override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
bool connected() const;
bool connectionFailed() const;
bool initialized() const { return initialized_; }
bool rejected() const { return rejected_; }
private:
friend class RemoteGameServerHost;
struct CompletedSimulationTick {
std::string locationId;
LocationSimulationEvents events;
};
std::vector<CompletedSimulationTick> takeCompletedSimulationTicks();
void expectCompatibility(std::string fingerprint) {
expectedCompatibilityFingerprint_ = std::move(fingerprint);
}
class Impl;
InternalGameServer replica_;
std::unique_ptr<Impl> impl_;
std::map<std::uint64_t, TickMessage> pendingTicks_;
std::vector<CompletedSimulationTick> completedSimulationTicks_;
std::vector<GameServerEvent> pendingEvents_;
std::uint64_t nextExpectedTick_ = 0;
std::uint64_t nextClientSequence_ = 1;
std::string compatibilityFingerprint_;
std::string expectedCompatibilityFingerprint_;
bool initialized_ = false;
bool rejected_ = false;
bool loggedFirstTick_ = false;
};
} // namespace FRG
+333
View File
@@ -0,0 +1,333 @@
#include "WebsocketGameServer.h"
#include "InternalGameServer.h"
#include "NetworkProtocol.h"
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <algorithm>
#include <deque>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <vector>
namespace FRG {
namespace {
namespace asio = boost::asio;
namespace beast = boost::beast;
namespace websocket = beast::websocket;
using tcp = asio::ip::tcp;
class ServerSession : public std::enable_shared_from_this<ServerSession> {
public:
using InputHandler = std::function<void(
const std::shared_ptr<ServerSession>&, const std::string&)>;
using CloseHandler =
std::function<void(const std::shared_ptr<ServerSession>&)>;
ServerSession(
tcp::socket socket, std::string playerId,
InputHandler inputHandler, CloseHandler closeHandler)
: socket_(std::move(socket))
, playerId_(std::move(playerId))
, inputHandler_(std::move(inputHandler))
, closeHandler_(std::move(closeHandler))
{
}
const std::string& playerId() const { return playerId_; }
void start(std::string initialState, bool closeAfterWrite = false)
{
closeAfterWrite_ = closeAfterWrite;
socket_.set_option(
websocket::stream_base::timeout::suggested(beast::role_type::server));
socket_.async_accept(
[self = shared_from_this(), initialState = std::move(initialState)](
beast::error_code error) mutable {
if (error) {
std::cerr << "[network] WebSocket accept failed: "
<< error.message() << '\n';
return;
}
self->socket_.text(true);
self->send(std::move(initialState));
if (!self->closeAfterWrite_) self->read();
});
}
void send(std::string message)
{
const bool writeInProgress = !outbound_.empty();
outbound_.push_back(std::move(message));
if (!writeInProgress) write();
}
private:
void read()
{
socket_.async_read(
inbound_,
[self = shared_from_this()](beast::error_code error, std::size_t) {
if (error) {
if (error != websocket::error::closed)
std::cerr << "[network] WebSocket read failed: "
<< error.message() << '\n';
self->notifyClosed();
return;
}
const std::string message =
beast::buffers_to_string(self->inbound_.data());
self->inbound_.consume(self->inbound_.size());
self->inputHandler_(self, message);
self->read();
});
}
void write()
{
socket_.async_write(
asio::buffer(outbound_.front()),
[self = shared_from_this()](beast::error_code error, std::size_t) {
if (error) {
std::cerr << "[network] WebSocket write failed: "
<< error.message() << '\n';
return;
}
self->outbound_.pop_front();
if (!self->outbound_.empty()) {
self->write();
} else if (self->closeAfterWrite_) {
self->socket_.async_close(
websocket::close_code::policy_error,
[self](beast::error_code) { self->notifyClosed(); });
}
});
}
void notifyClosed()
{
if (closed_) return;
closed_ = true;
if (closeHandler_) closeHandler_(shared_from_this());
}
websocket::stream<tcp::socket> socket_;
beast::flat_buffer inbound_;
std::deque<std::string> outbound_;
std::string playerId_;
InputHandler inputHandler_;
CloseHandler closeHandler_;
bool closeAfterWrite_ = false;
bool closed_ = false;
};
} // namespace
class WebsocketGameServer::Impl {
public:
struct PendingInput {
std::string playerId;
DestinationInputMessage input;
};
Impl(
const std::string& address, std::uint16_t port,
std::vector<std::string> playerSlots, nlohmann::json initialState,
std::string compatibilityFingerprint)
: acceptor_(io_)
, playerSlots_(std::move(playerSlots))
, latestState_(std::move(initialState))
, compatibilityFingerprint_(std::move(compatibilityFingerprint))
{
const tcp::endpoint endpoint(asio::ip::make_address(address), port);
acceptor_.open(endpoint.protocol());
acceptor_.set_option(asio::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen(asio::socket_base::max_listen_connections);
port_ = acceptor_.local_endpoint().port();
accept();
thread_ = std::thread([this]() { io_.run(); });
}
~Impl()
{
asio::post(io_, [this]() {
beast::error_code ignored;
acceptor_.close(ignored);
});
io_.stop();
if (thread_.joinable()) thread_.join();
}
std::vector<PendingInput> takeInputs()
{
std::lock_guard<std::mutex> lock(inputsMutex_);
std::vector<PendingInput> result;
result.swap(pendingInputs_);
return result;
}
std::vector<std::string> takeReleasedPlayers()
{
std::lock_guard<std::mutex> lock(inputsMutex_);
std::vector<std::string> result;
result.swap(releasedPlayers_);
return result;
}
void publishTick(
std::string tick, nlohmann::json latestState, std::uint64_t tickId)
{
asio::post(io_, [this, tick = std::move(tick),
latestState = std::move(latestState), tickId]() mutable {
latestState_ = std::move(latestState);
latestTick_ = tickId;
auto output = sessions_.begin();
for (auto input = sessions_.begin(); input != sessions_.end(); ++input) {
if (auto session = input->lock()) {
session->send(tick);
*output++ = *input;
}
}
sessions_.erase(output, sessions_.end());
});
}
std::uint16_t port() const { return port_; }
private:
void accept()
{
acceptor_.async_accept(
[this](beast::error_code error, tcp::socket socket) {
if (!error) {
std::string playerId;
for (const std::string& candidate : playerSlots_) {
if (assignedSlots_.count(candidate) == 0) {
playerId = candidate;
assignedSlots_[candidate] = true;
break;
}
}
auto session =
std::make_shared<ServerSession>(
std::move(socket), playerId,
[this](const std::shared_ptr<ServerSession>& source,
const std::string& message) {
DestinationInputMessage parsed;
std::string parseError;
if (!parseDestinationInputMessage(
message, parsed, parseError)) {
source->send(makeErrorMessage(
"invalid_input", parseError));
return;
}
std::lock_guard<std::mutex> lock(inputsMutex_);
pendingInputs_.push_back(
{source->playerId(), std::move(parsed)});
},
[this](const std::shared_ptr<ServerSession>& source) {
if (!source->playerId().empty())
std::cout << "[network] Player slot '"
<< source->playerId()
<< "' disconnected" << std::endl;
assignedSlots_.erase(source->playerId());
std::lock_guard<std::mutex> lock(inputsMutex_);
releasedPlayers_.push_back(source->playerId());
});
if (playerId.empty()) {
std::cout << "[network] Rejecting connection: no player slot"
<< std::endl;
session->start(
makeErrorMessage(
"server_full", "No multiplayer player slot is available"),
true);
} else {
std::cout << "[network] Accepted connection as player slot '"
<< playerId << "' at snapshot tick "
<< latestTick_ << std::endl;
sessions_.push_back(session);
InitialStateMessage initial;
initial.state = latestState_;
initial.assignedPlayerId = playerId;
initial.snapshotTick = latestTick_;
initial.compatibilityFingerprint =
compatibilityFingerprint_;
session->start(makeInitialStateMessage(initial));
std::cout << "[network] Initial state queued for player '"
<< playerId << "'" << std::endl;
}
} else if (error != asio::error::operation_aborted) {
std::cerr << "[network] TCP accept failed: "
<< error.message() << '\n';
}
if (acceptor_.is_open()) accept();
});
}
asio::io_context io_;
tcp::acceptor acceptor_;
std::thread thread_;
std::vector<std::weak_ptr<ServerSession>> sessions_;
std::vector<std::string> playerSlots_;
std::unordered_map<std::string, bool> assignedSlots_;
nlohmann::json latestState_;
std::string compatibilityFingerprint_;
std::uint64_t latestTick_ = 0;
std::mutex inputsMutex_;
std::vector<PendingInput> pendingInputs_;
std::vector<std::string> releasedPlayers_;
std::uint16_t port_ = 0;
};
WebsocketGameServer::WebsocketGameServer(
InternalGameServer& gameServer,
std::vector<std::string> playerSlots,
std::string compatibilityFingerprint,
const std::string& address,
std::uint16_t port)
: gameServer_(gameServer)
{
nlohmann::json state;
gameServer_.save(state);
impl_ = std::make_unique<Impl>(
address, port, std::move(playerSlots), std::move(state),
std::move(compatibilityFingerprint));
}
WebsocketGameServer::~WebsocketGameServer() = default;
void WebsocketGameServer::update()
{
for (const std::string& playerId : impl_->takeReleasedPlayers())
lastClientSequences_.erase(playerId);
for (const Impl::PendingInput& pending : impl_->takeInputs()) {
std::uint64_t& previous = lastClientSequences_[pending.playerId];
if (pending.input.clientSequence <= previous) continue;
previous = pending.input.clientSequence;
timeline_.schedule(pending.playerId, pending.input.target);
}
TickMessage tick = timeline_.advance();
for (const ScheduledDestinationCommand& command : tick.commands) {
gameServer_.submit(SetCharacterDestinationCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId, command.target, {}});
}
gameServer_.update(kSimulationTickMs);
nlohmann::json state;
gameServer_.save(state);
tick.stateHash = canonicalStateHash(state);
impl_->publishTick(makeTickMessage(tick), std::move(state), tick.tickId);
}
std::uint16_t WebsocketGameServer::port() const
{
return impl_->port();
}
} // namespace FRG
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "NetworkProtocol.h"
namespace FRG {
class InternalGameServer;
class WebsocketGameServer {
public:
WebsocketGameServer(
InternalGameServer& gameServer,
std::vector<std::string> playerSlots,
std::string compatibilityFingerprint,
const std::string& address = "0.0.0.0",
std::uint16_t port = 8080);
~WebsocketGameServer();
WebsocketGameServer(const WebsocketGameServer&) = delete;
WebsocketGameServer& operator=(const WebsocketGameServer&) = delete;
void update();
std::uint16_t port() const;
private:
class Impl;
InternalGameServer& gameServer_;
std::unique_ptr<Impl> impl_;
LockstepTimeline timeline_;
std::unordered_map<std::string, std::uint64_t> lastClientSequences_;
};
} // namespace FRG
+84 -119
View File
@@ -8,6 +8,7 @@
#include <Eigen/Geometry>
#include "../Character.h"
#include <SDL.h>
#include <unordered_set>
namespace FRG {
@@ -91,6 +92,9 @@ namespace FRG {
for (const auto& item : j["objects"]) {
InteractiveObjectData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("Interactive object id must not be empty in " + jsonPath);
data.base = parseGameObjectData(item);
data.interactionRadius = item.value("interactionRadius", 2.0f);
data.approachRadius = item.value("approachRadius", data.interactionRadius);
@@ -98,20 +102,21 @@ namespace FRG {
data.castShadowNight = item.value("castShadowNight", true);
data.isActive = item.value("isActive", true);
data.activateFunctionName = item.value("activateFunction", "");
data.pivotX = item.value("pivotX", 0.0f);
data.pivotY = item.value("pivotY", 0.0f);
data.pivotZ = item.value("pivotZ", 0.0f);
data.boundsMinX = item.value("boundsMinX", 0.0f);
data.boundsMinY = item.value("boundsMinY", 0.0f);
data.boundsMinZ = item.value("boundsMinZ", 0.0f);
data.boundsMaxX = item.value("boundsMaxX", 0.0f);
data.boundsMaxY = item.value("boundsMaxY", 0.0f);
data.boundsMaxZ = item.value("boundsMaxZ", 0.0f);
data.pivot = {
item.value("pivotX", 0.0f), item.value("pivotY", 0.0f),
item.value("pivotZ", 0.0f)};
data.boundsMin = {
item.value("boundsMinX", 0.0f), item.value("boundsMinY", 0.0f),
item.value("boundsMinZ", 0.0f)};
data.boundsMax = {
item.value("boundsMaxX", 0.0f), item.value("boundsMaxY", 0.0f),
item.value("boundsMaxZ", 0.0f)};
if (item.contains("interactionPositionX")) {
data.hasInteractionPosition = true;
data.interactionPositionX = item.value("interactionPositionX", 0.0f);
data.interactionPositionY = item.value("interactionPositionY", 0.0f);
data.interactionPositionZ = item.value("interactionPositionZ", 0.0f);
data.interactionPosition = {
item.value("interactionPositionX", 0.0f),
item.value("interactionPositionY", 0.0f),
item.value("interactionPositionZ", 0.0f)};
}
if (!data.base.meshPath.empty())
objects.push_back(std::move(data));
@@ -121,40 +126,6 @@ namespace FRG {
return objects;
}
LoadedGameObjectState GameObjectData::toLoadedGameObjectState() const {
LoadedGameObjectState st;
st.texturePath = texturePath;
st.textureDarkandsPath = textureDarkandsPath;
st.meshPath = meshPath;
st.meshRotationX = rotationX;
st.meshRotationY = rotationY;
st.meshRotationZ = rotationZ;
st.meshScale = scale;
return st;
}
InteractiveObjectState InteractiveObjectData::toInteractiveObjectState() const {
InteractiveObjectState st;
st.objectInfo = base.toLoadedGameObjectState();
st.interactionRadius = interactionRadius;
st.approachRadius = approachRadius;
st.castShadow = castShadow;
st.castShadowNight = castShadowNight;
st.isActive = isActive;
st.activateFunctionName = activateFunctionName;
st.pivot = Eigen::Vector3f(pivotX, pivotY, pivotZ);
st.boundsMin = Eigen::Vector3f(boundsMinX, boundsMinY, boundsMinZ);
st.boundsMax = Eigen::Vector3f(boundsMaxX, boundsMaxY, boundsMaxZ);
st.hasInteractionPosition = hasInteractionPosition;
if (hasInteractionPosition)
st.interactionPosition = Eigen::Vector3f(interactionPositionX, interactionPositionY, interactionPositionZ);
st.jsonPositionX = base.positionX;
st.jsonPositionY = base.positionY;
st.jsonPositionZ = base.positionZ;
// state.position is set by InteractiveObject::createFromState() after mesh-center computation
return st;
}
static VertexDataStruct loadMeshData(const std::string& meshPath, const std::string& zipPath)
{
if (meshPath.size() > 4 && meshPath.compare(meshPath.size() - 4, 4, ".bin") == 0)
@@ -209,20 +180,18 @@ namespace FRG {
// Public API
// -----------------------------------------------------------------------
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
const std::string& jsonPath,
StaticObjectSet GameObjectLoader::createGameObjects(
const std::vector<GameObjectData>& definitions,
Renderer& renderer,
const std::string& zipPath)
{
StaticObjectSet result;
std::vector<GameObjectData> allData = loadFromJson(jsonPath, zipPath);
// Group objects sharing mesh + textures (in file order) so they can be drawn instanced.
std::vector<std::vector<const GameObjectData*>> groups;
std::unordered_map<std::string, size_t> groupIndexByKey;
for (const auto& data : allData) {
for (const auto& data : definitions) {
if (!data.instanced) {
groups.push_back({ &data });
continue;
@@ -290,22 +259,33 @@ namespace FRG {
return result;
}
std::vector<InteractiveObject> GameObjectLoader::loadAndCreateInteractiveObjects(
const std::string& jsonPath,
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
const std::string& jsonPath, Renderer& renderer,
const std::string& zipPath)
{
return createGameObjects(
loadFromJson(jsonPath, zipPath), renderer, zipPath);
}
std::vector<InteractiveObject> GameObjectLoader::createInteractiveObjects(
const std::vector<InteractiveObjectData>& definitions,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath)
{
std::vector<InteractiveObject> interactiveObjects;
if (jsonPath.empty())
return interactiveObjects;
for (const auto& data : loadInteractiveFromJson(jsonPath, zipPath)) {
for (const auto& data : definitions) {
SDL_PumpEvents();
logger() << "Loading interactive object: " << data.base.name << std::endl;
try {
InteractiveObject intObj = InteractiveObject::createFromState(
data.toInteractiveObjectState(), renderer, zipPath);
auto state = states.find(data.id);
if (state == states.end() || !state->second)
throw std::runtime_error(
"Authoritative state missing for '" + data.id + "'");
InteractiveObject intObj =
InteractiveObject::createFromState(
*state->second, renderer, zipPath);
intObj.id = data.id;
intObj.loadedObject.name = data.base.name;
if (!data.activateFunctionName.empty())
@@ -324,6 +304,18 @@ namespace FRG {
return interactiveObjects;
}
std::vector<InteractiveObject>
GameObjectLoader::loadAndCreateInteractiveObjects(
const std::string& jsonPath, Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath)
{
return createInteractiveObjects(
loadInteractiveFromJson(jsonPath, zipPath), renderer, states,
zipPath);
}
std::vector<NpcData> GameObjectLoader::loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath)
{
std::vector<NpcData> npcs;
@@ -338,7 +330,9 @@ namespace FRG {
for (const auto& item : j["npcs"]) {
NpcData data;
data.id = item.value("id", "npc_unknown");
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("NPC id must not be empty in " + jsonPath);
data.name = item.value("name", "Unknown NPC");
data.texturePath = item.value("texturePath", "");
if (item.contains("meshTextures") && item["meshTextures"].is_object()) {
@@ -376,88 +370,59 @@ namespace FRG {
logger() << "Successfully loaded " << npcs.size() << " NPCs from " << jsonPath << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error loading NPCs from JSON: " << e.what() << std::endl;
throw;
}
return npcs;
}
CharacterState NpcData::toCharacterState() const {
CharacterState st;
// Identity + game state
st.npcId = id;
st.npcName = name;
st.position = Eigen::Vector3f(positionX, positionY, positionZ);
st.facingAngle = facingAngle;
st.walkSpeed = walkSpeed;
st.rotationSpeed = rotationSpeed;
st.modelScale = modelScale;
st.setHp(hp);
st.initialHp = hp;
st.visibleRange = visibleRange;
st.canAttack = canAttack;
st.enabled = enabled;
st.interactionRadius = interactionRadius;
Eigen::Quaternionf corrRot = Eigen::Quaternionf::Identity();
if (modelCorrectionRotX != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotX, Eigen::Vector3f::UnitX())) * corrRot;
if (modelCorrectionRotY != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotY, Eigen::Vector3f::UnitY())) * corrRot;
if (modelCorrectionRotZ != 0.0f)
corrRot = Eigen::Quaternionf(Eigen::AngleAxisf(modelCorrectionRotZ, Eigen::Vector3f::UnitZ())) * corrRot;
st.modelCorrectionRotation = corrRot;
// Creation info: animation paths
CharacterCreationInfo& info = st.creationInfo;
auto addAnim = [&](AnimationState animState, const std::string& path) {
if (!path.empty())
info.animationPaths.emplace_back(animState, path);
};
addAnim(AnimationState::STAND, animationIdlePath);
addAnim(AnimationState::WALK, animationWalkPath);
addAnim(AnimationState::ACTION_IDLE, animationActionIdlePath);
addAnim(AnimationState::ACTION_ATTACK, animationActionAttackPath);
addAnim(AnimationState::STAND_TO_ACTION, animationStandToActionPath);
addAnim(AnimationState::ACTION_TO_STAND, animationActionToStandPath);
addAnim(AnimationState::ACTION_TO_DEATH, animationActionToDeathPath);
addAnim(AnimationState::DEATH_IDLE, animationDeathIdlePath);
// Creation info: textures
info.meshTexturePaths = std::unordered_map<std::string, std::string>(meshTextures.begin(), meshTextures.end());
info.singleTexturePath = texturePath;
return st;
}
std::vector<std::unique_ptr<Character>> GameObjectLoader::loadAndCreate_Npcs(
const std::string& jsonPath,
std::vector<std::unique_ptr<Character>> GameObjectLoader::createNpcs(
const std::vector<NpcData>& definitions,
Renderer& renderer,
const std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath)
{
std::vector<std::unique_ptr<Character>> npcs;
int index = 0;
for (const auto& npcData : loadNpcsFromJson(jsonPath, zipPath)) {
for (const auto& npcData : definitions) {
SDL_PumpEvents();
logger() << "Loading NPC: " << npcData.name << std::endl;
try {
auto npc = Character::createFromState(npcData.toCharacterState(), renderer, zipPath.c_str());
auto state = states.find(npcData.id);
if (state == states.end() || !state->second)
throw std::runtime_error(
"Authoritative state missing for '" +
npcData.id + "'");
auto npc = Character::createFromState(
*state->second, server, locationId, npcData.id,
renderer, zipPath.c_str());
if (npc) {
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
npc->index = index;
npcs.push_back(std::move(npc));
index = index + 1;
}
} catch (const std::exception& e) {
std::cerr << "GameObjectLoader: Failed to load NPC '" << npcData.name << "': " << e.what() << std::endl;
}
}
logger() << "Total NPCs loaded: " << npcs.size() << std::endl;
return npcs;
}
std::vector<std::unique_ptr<Character>>
GameObjectLoader::loadAndCreate_Npcs(
const std::string& jsonPath, Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server, const std::string& locationId,
const std::string& zipPath)
{
return createNpcs(
loadNpcsFromJson(jsonPath, zipPath), renderer, states, server,
locationId, zipPath);
}
} // namespace FRG
+24 -74
View File
@@ -9,30 +9,13 @@
#include "InteractiveObject.h"
#include "InteractiveObjectState.h"
#include "../CharacterState.h"
#include "../content/LocationDefinition.h"
namespace FRG {
class IGameServer;
class Character;
struct GameObjectData {
std::string name;
std::string texturePath;
std::string textureDarkandsPath;
std::string meshPath;
float rotationX = 0.0f;
float rotationY = 0.0f;
float rotationZ = 0.0f;
float positionX = 0.0f;
float positionY = 0.0f;
float positionZ = 0.0f;
float scale = 1.0f;
// Objects sharing mesh and textures are drawn with one instanced call.
// Set "instanced": false in the JSON to keep an object as a standalone mesh.
bool instanced = true;
LoadedGameObjectState toLoadedGameObjectState() const;
};
// Result of loading a static object list: unique objects keep their own mesh,
// repeated ones are merged into instanced batches.
struct StaticObjectSet {
@@ -40,62 +23,25 @@ namespace FRG {
std::vector<InstancedBatch> batches;
};
struct InteractiveObjectData {
GameObjectData base;
float interactionRadius = 2.0f;
float approachRadius = 2.0f;
std::string activateFunctionName;
float pivotX = 0.0f;
float pivotY = 0.0f;
float pivotZ = 0.0f;
float boundsMinX = 0.0f, boundsMinY = 0.0f, boundsMinZ = 0.0f;
float boundsMaxX = 0.0f, boundsMaxY = 0.0f, boundsMaxZ = 0.0f;
bool hasInteractionPosition = false;
float interactionPositionX = 0.0f, interactionPositionY = 0.0f, interactionPositionZ = 0.0f;
bool castShadow = true;
bool castShadowNight = true;
bool isActive = true;
InteractiveObjectState toInteractiveObjectState() const;
};
struct NpcData {
std::string id;
std::string name;
std::string texturePath;
std::map<std::string, std::string> meshTextures;
std::string animationIdlePath;
std::string animationWalkPath;
// Optional combat / death animations. Empty path = slot not loaded.
std::string animationActionIdlePath;
std::string animationActionAttackPath;
std::string animationStandToActionPath;
std::string animationActionToStandPath;
std::string animationActionToDeathPath;
std::string animationDeathIdlePath;
float positionX = 0.0f;
float positionY = 0.0f;
float positionZ = 0.0f;
float walkSpeed = 1.5f;
float rotationSpeed = 8.0f;
float modelScale = 0.01f;
float modelCorrectionRotX = 0.0f;
float modelCorrectionRotY = 0.0f;
float modelCorrectionRotZ = 0.0f;
float facingAngle = 0.0f;
float hp = 100.0f;
float visibleRange = VISIBLE_RANGE;
bool canAttack = false;
bool enabled = true;
float interactionRadius = 0.0f;
// Converts this JSON-parsed descriptor into a fully-populated CharacterState
// (including creationInfo) ready for Character::createFromState().
CharacterState toCharacterState() const;
};
class GameObjectLoader {
public:
static StaticObjectSet createGameObjects(
const std::vector<GameObjectData>& definitions,
Renderer& renderer, const std::string& zipPath = "");
static std::vector<InteractiveObject> createInteractiveObjects(
const std::vector<InteractiveObjectData>& definitions,
Renderer& renderer,
std::unordered_map<std::string,
std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath = "");
static std::vector<std::unique_ptr<FRG::Character>> createNpcs(
const std::vector<NpcData>& definitions,
Renderer& renderer,
const std::unordered_map<std::string,
std::shared_ptr<CharacterState>>& states,
IGameServer& server, const std::string& locationId,
const std::string& zipPath);
static StaticObjectSet loadAndCreateGameObjects(
const std::string& jsonPath,
Renderer& renderer,
@@ -105,13 +51,17 @@ namespace FRG {
static std::vector<InteractiveObject> loadAndCreateInteractiveObjects(
const std::string& jsonPath,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath = ""
);
static std::vector<std::unique_ptr<FRG::Character>> loadAndCreate_Npcs(
const std::string& jsonPath,
Renderer& renderer,
const std::string& zipPath = ""
std::unordered_map<std::string, std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath
);
static std::vector<NpcData> loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath = "");
+4 -184
View File
@@ -19,12 +19,11 @@ namespace FRG {
using std::max;
#endif
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState st,
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState& st,
Renderer& renderer,
const std::string& zipPath)
{
InteractiveObject obj;
obj.state = std::move(st);
InteractiveObject obj(st);
const LoadedGameObjectState& info = obj.state.objectInfo;
@@ -52,7 +51,8 @@ namespace FRG {
obj.loadedObject.mesh.RefreshVBO();
// Center mesh at origin; adjust world position by the centering offset.
// Interactive-object definitions store the centered world position.
// Center the presentation mesh at the origin without changing state.
if (!obj.loadedObject.mesh.data.PositionData.empty()) {
Eigen::Vector3f meshMin = obj.loadedObject.mesh.data.PositionData[0];
Eigen::Vector3f meshMax = obj.loadedObject.mesh.data.PositionData[0];
@@ -63,129 +63,11 @@ namespace FRG {
const Eigen::Vector3f meshCenter = (meshMin + meshMax) * 0.5f;
obj.loadedObject.mesh.data.Move(-meshCenter);
obj.loadedObject.mesh.RefreshVBO();
obj.state.position = Eigen::Vector3f(obj.state.jsonPositionX,
obj.state.jsonPositionY,
obj.state.jsonPositionZ) + meshCenter;
} else {
obj.state.position = Eigen::Vector3f(obj.state.jsonPositionX,
obj.state.jsonPositionY,
obj.state.jsonPositionZ);
}
return obj;
}
void InteractiveObject::moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = target;
task.targetRotY = state.rotationY;
task.targetScale = state.scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
}
void InteractiveObject::rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = state.position;
task.targetRotY = targetRotY;
task.targetScale = state.scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
}
void InteractiveObject::scaleTo(float targetScale, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Scale;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = state.position;
task.targetRotY = state.rotationY;
task.targetScale = targetScale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
}
void InteractiveObject::fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Fade;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.alpha; // reuse startScale to hold start alpha
task.targetPos = state.position;
task.targetRotY = state.rotationY;
task.targetScale = targetAlpha; // reuse targetScale to hold target alpha
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
}
void InteractiveObject::update(int64_t deltaMs) {
if (!state.isAnimating || !state.animTask) return;
AnimTask& task = *state.animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float t = min(task.elapsedMs / task.durationMs, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
state.position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
state.rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
state.scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
state.alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t >= 1.0f) {
state.position = task.targetPos;
state.rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
state.alpha = task.targetScale;
else
state.scale = task.targetScale;
std::function<void()> cb = std::move(task.onComplete);
state.animTask.reset();
state.isAnimating = false;
if (cb) cb();
}
}
void InteractiveObject::drawDarklands(Renderer& renderer) const {
if (!state.isActive || !loadedObject.textureDarklands) return;
@@ -238,66 +120,4 @@ namespace FRG {
renderer.RenderUniform1f("uAlpha", 1.0f);
}
void InteractiveObjectState::save(nlohmann::json& out) const
{
out["positionX"] = position.x();
out["positionY"] = position.y();
out["positionZ"] = position.z();
out["rotationY"] = rotationY;
out["scale"] = scale;
out["alpha"] = alpha;
out["isActive"] = isActive;
out["isAnimating"] = isAnimating;
if (isAnimating && animTask.has_value()) {
nlohmann::json at;
at["type"] = static_cast<int>(animTask->type);
at["startPosX"] = animTask->startPos.x();
at["startPosY"] = animTask->startPos.y();
at["startPosZ"] = animTask->startPos.z();
at["startRotY"] = animTask->startRotY;
at["startScale"] = animTask->startScale;
at["targetPosX"] = animTask->targetPos.x();
at["targetPosY"] = animTask->targetPos.y();
at["targetPosZ"] = animTask->targetPos.z();
at["targetRotY"] = animTask->targetRotY;
at["targetScale"] = animTask->targetScale;
at["durationMs"] = animTask->durationMs;
at["elapsedMs"] = animTask->elapsedMs;
if (!animTask->onCompleteName.empty())
at["onCompleteName"] = animTask->onCompleteName;
out["animTask"] = std::move(at);
}
}
void InteractiveObjectState::load(const nlohmann::json& in)
{
position.x() = in.value("positionX", position.x());
position.y() = in.value("positionY", position.y());
position.z() = in.value("positionZ", position.z());
rotationY = in.value("rotationY", rotationY);
scale = in.value("scale", scale);
alpha = in.value("alpha", alpha);
isActive = in.value("isActive", isActive);
isAnimating = in.value("isAnimating", false);
animTask.reset();
if (isAnimating && in.contains("animTask") && in["animTask"].is_object()) {
const auto& at = in["animTask"];
AnimTask task;
task.type = static_cast<AnimTask::Type>(at.value("type", 0));
task.startPos = { at.value("startPosX", 0.f), at.value("startPosY", 0.f), at.value("startPosZ", 0.f) };
task.startRotY = at.value("startRotY", 0.f);
task.startScale = at.value("startScale", 1.f);
task.targetPos = { at.value("targetPosX", 0.f), at.value("targetPosY", 0.f), at.value("targetPosZ", 0.f) };
task.targetRotY = at.value("targetRotY", 0.f);
task.targetScale = at.value("targetScale", 1.f);
task.durationMs = at.value("durationMs", 1000.f);
task.elapsedMs = at.value("elapsedMs", 0.f);
task.onCompleteName = at.value("onCompleteName", std::string());
// onComplete is non-serializable; re-wired at runtime by Lua script callbacks.
animTask = std::move(task);
}
}
} // namespace FRG
+12 -14
View File
@@ -1,9 +1,6 @@
#pragma once
#include <string>
#include <memory>
#include <optional>
#include <functional>
#include <cstdint>
#include <Eigen/Core>
#include "render/Renderer.h"
#include "InteractiveObjectState.h"
@@ -21,25 +18,26 @@ namespace FRG {
};
struct InteractiveObject {
explicit InteractiveObject(InteractiveObjectState& boundState) : state(boundState) {}
std::string id;
LoadedGameObject loadedObject; // rendering assets
InteractiveObjectState state; // all mutable + creation state
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
// Loads textures and mesh from state.objectInfo, computes mesh centering,
// and sets state.position = jsonPosition + meshCenter.
static InteractiveObject createFromState(InteractiveObjectState st,
// Loads textures and centers the mesh around the state position.
static InteractiveObject createFromState(InteractiveObjectState& state,
Renderer& renderer,
const std::string& zipPath = "");
// callbackName, if given, is the global Lua function name backing onComplete —
// stored in the AnimTask so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void scaleTo(float targetScale, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void update(int64_t deltaMs);
void draw(Renderer& renderer) const;
void drawDarklands(Renderer& renderer) const;
const std::string& entityId() const { return id; }
private:
friend class GameObjectLoader;
friend class Location;
friend class LocationEditor;
friend class ScriptEngine;
InteractiveObjectState& state; // non-owning binding to LocationGameState
};
} // namespace FRG
+181
View File
@@ -0,0 +1,181 @@
#include "InteractiveObjectState.h"
#include <algorithm>
#include <utility>
namespace FRG {
void InteractiveObjectState::moveTo(
const Eigen::Vector3f& target, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = target;
task.targetRotY = rotationY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::rotateTo(
float targetRotY, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = targetRotY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::scaleTo(
float targetScale, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Scale;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetScale;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObjectState::fadeTo(
float targetAlpha, float durationSec, std::string onCompleteName)
{
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Fade;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = alpha;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetAlpha;
task.durationMs = durationSec * 1000.f;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
InteractiveObjectUpdateEvents InteractiveObjectState::update(std::int64_t deltaMs)
{
InteractiveObjectUpdateEvents events;
if (!isAnimating || !animTask) return events;
AnimTask& task = *animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float duration = (std::max)(task.durationMs, 0.001f);
const float t = (std::min)(task.elapsedMs / duration, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t < 1.0f) return events;
position = task.targetPos;
rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
alpha = task.targetScale;
else
scale = task.targetScale;
events.onCompleteName = std::move(task.onCompleteName);
animTask.reset();
isAnimating = false;
return events;
}
void InteractiveObjectState::save(nlohmann::json& out) const
{
out["positionX"] = position.x();
out["positionY"] = position.y();
out["positionZ"] = position.z();
out["rotationY"] = rotationY;
out["scale"] = scale;
out["alpha"] = alpha;
out["isActive"] = isActive;
out["isAnimating"] = isAnimating;
if (isAnimating && animTask) {
out["animTask"] = {
{"type", static_cast<int>(animTask->type)},
{"startPosX", animTask->startPos.x()},
{"startPosY", animTask->startPos.y()},
{"startPosZ", animTask->startPos.z()},
{"startRotY", animTask->startRotY},
{"startScale", animTask->startScale},
{"targetPosX", animTask->targetPos.x()},
{"targetPosY", animTask->targetPos.y()},
{"targetPosZ", animTask->targetPos.z()},
{"targetRotY", animTask->targetRotY},
{"targetScale", animTask->targetScale},
{"durationMs", animTask->durationMs},
{"elapsedMs", animTask->elapsedMs},
{"onCompleteName", animTask->onCompleteName}};
}
}
void InteractiveObjectState::load(const nlohmann::json& in)
{
position.x() = in.value("positionX", position.x());
position.y() = in.value("positionY", position.y());
position.z() = in.value("positionZ", position.z());
rotationY = in.value("rotationY", rotationY);
scale = in.value("scale", scale);
alpha = in.value("alpha", alpha);
isActive = in.value("isActive", isActive);
isAnimating = in.value("isAnimating", false);
animTask.reset();
if (!isAnimating || !in.contains("animTask") ||
!in["animTask"].is_object()) {
return;
}
const auto& value = in["animTask"];
AnimTask task;
task.type = static_cast<AnimTask::Type>(value.value("type", 0));
task.startPos = {value.value("startPosX", 0.f),
value.value("startPosY", 0.f), value.value("startPosZ", 0.f)};
task.startRotY = value.value("startRotY", 0.f);
task.startScale = value.value("startScale", 1.f);
task.targetPos = {value.value("targetPosX", 0.f),
value.value("targetPosY", 0.f), value.value("targetPosZ", 0.f)};
task.targetRotY = value.value("targetRotY", 0.f);
task.targetScale = value.value("targetScale", 1.f);
task.durationMs = value.value("durationMs", 1000.f);
task.elapsedMs = value.value("elapsedMs", 0.f);
task.onCompleteName =
value.value("onCompleteName", std::string());
animTask = std::move(task);
}
} // namespace FRG
+12 -5
View File
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <optional>
#include <functional>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -21,10 +21,11 @@ struct AnimTask {
float targetScale = 1.f;
float durationMs = 1000.f;
float elapsedMs = 0.f;
std::function<void()> onComplete; // non-serializable; re-wired at runtime by Lua callbacks
// Global Lua function name backing onComplete, if it was sourced from a script
// (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback can be
// re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the animation completes. Empty means none.
std::string onCompleteName;
};
struct InteractiveObjectUpdateEvents {
std::string onCompleteName;
};
@@ -70,6 +71,12 @@ public:
bool isAnimating = false;
std::optional<AnimTask> animTask;
void moveTo(const Eigen::Vector3f& target, float durationSec, std::string onCompleteName = {});
void rotateTo(float targetRotY, float durationSec, std::string onCompleteName = {});
void scaleTo(float targetScale, float durationSec, std::string onCompleteName = {});
void fadeTo(float targetAlpha, float durationSec, std::string onCompleteName = {});
InteractiveObjectUpdateEvents update(std::int64_t deltaMs);
// --- Serialisation (runtime mutable fields only) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
+43
View File
@@ -0,0 +1,43 @@
#include "Item.h"
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <stdexcept>
namespace FRG {
bool Inventory::addItem(const std::string& itemId)
{
itemIds.push_back(itemId);
return true;
}
bool Inventory::removeItem(const std::string& itemId)
{
auto it = std::find(itemIds.begin(), itemIds.end(), itemId);
if (it == itemIds.end()) return false;
itemIds.erase(it);
return true;
}
bool Inventory::hasItem(const std::string& itemId) const
{
return std::find(itemIds.begin(), itemIds.end(), itemId) != itemIds.end();
}
void Inventory::save(nlohmann::json& out) const
{
out["items"] = itemIds;
}
void Inventory::load(const nlohmann::json& in)
{
itemIds.clear();
if (!in.contains("items") || !in["items"].is_array()) return;
for (const auto& item : in["items"]) {
if (!item.is_string())
throw std::runtime_error("Inventory item IDs must be strings");
itemIds.push_back(item.get<std::string>());
}
}
} // namespace FRG
-41
View File
@@ -1,31 +1,8 @@
#include "Item.h"
#include "ItemRegistry.h"
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <iostream>
#include "../utils/Utils.h"
namespace FRG {
void Inventory::addItem(const std::string& itemId) {
itemIds.push_back(itemId);
logger() << "Item added to inventory: " << itemId << std::endl;
if (onItemAdded) onItemAdded(itemId);
}
void Inventory::removeItem(const std::string& itemId) {
auto it = std::find(itemIds.begin(), itemIds.end(), itemId);
if (it != itemIds.end()) {
logger() << "Item removed from inventory: " << itemId << std::endl;
if (onItemRemoved) onItemRemoved(itemId);
itemIds.erase(it);
}
}
bool Inventory::hasItem(const std::string& itemId) const {
return std::find(itemIds.begin(), itemIds.end(), itemId) != itemIds.end();
}
std::vector<Item> Inventory::getItems() const {
std::vector<Item> result;
result.reserve(itemIds.size());
@@ -36,22 +13,4 @@ namespace FRG {
return result;
}
void Inventory::save(nlohmann::json& out) const {
out["items"] = itemIds;
}
void Inventory::load(const nlohmann::json& in) {
itemIds.clear();
if (!in.contains("items") || !in["items"].is_array()) return;
for (const auto& j : in["items"]) {
if (j.is_string()) {
itemIds.push_back(j.get<std::string>());
} else if (j.is_object()) {
// Backward-compat with old save files that stored full item structs.
const std::string id = j.value("id", "");
if (!id.empty()) itemIds.push_back(id);
}
}
}
} // namespace FRG
+2 -7
View File
@@ -2,7 +2,6 @@
#include <string>
#include <memory>
#include <vector>
#include <functional>
#include "ISaveable.h"
namespace FRG {
@@ -22,8 +21,8 @@ namespace FRG {
class Inventory : public ISaveable {
public:
void addItem(const std::string& itemId);
void removeItem(const std::string& itemId);
bool addItem(const std::string& itemId);
bool removeItem(const std::string& itemId);
// Resolves current items from ItemRegistry by id, so returned data
// always reflects the currently loaded (localized) definitions.
std::vector<Item> getItems() const;
@@ -34,10 +33,6 @@ namespace FRG {
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
// Called whenever an item is added/removed; receives the item id.
std::function<void(const std::string&)> onItemAdded;
std::function<void(const std::string&)> onItemRemoved;
private:
std::vector<std::string> itemIds;
};
+8 -3
View File
@@ -148,7 +148,8 @@ std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
bool operator<(const QueueNode& other) const
{
return priority > other.priority;
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
};
@@ -302,7 +303,8 @@ std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
bool operator<(const QueueNode& other) const
{
return priority > other.priority;
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
};
@@ -412,7 +414,10 @@ std::vector<Eigen::Vector3f> PathFinder::findNearestReachableImpl(
struct QueueNode {
int index = 0;
float priority = 0.0f;
bool operator<(const QueueNode& other) const { return priority > other.priority; }
bool operator<(const QueueNode& other) const {
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
};
const int cellCount = gridWidth * gridDepth;
-1
View File
@@ -1,6 +1,5 @@
#pragma once
#include "render/Renderer.h"
#include <Eigen/Core>
#include <string>
#include <vector>
+43 -26
View File
@@ -106,38 +106,48 @@ bool QuestJournal::setStatus(const std::string& questId, QuestStatus status) {
return true;
}
bool QuestJournal::unlockQuest(const std::string& questId) {
QuestMutationResult QuestJournal::unlockQuest(const std::string& questId) {
QuestMutationResult result;
QuestState* quest = findQuest(questId);
if (!quest) return false;
if (!quest) return result;
result.success = true;
if (quest->status == QuestStatus::Hidden) {
quest->status = QuestStatus::Available;
if (onQuestUnlocked) onQuestUnlocked(questId);
result.events.push_back({QuestEventType::Unlocked, questId, {}});
}
return true;
return result;
}
bool QuestJournal::completeQuest(const std::string& questId) {
bool changed = setStatus(questId, QuestStatus::Completed);
if (changed && onQuestCompleted) onQuestCompleted(questId);
return changed;
QuestMutationResult QuestJournal::completeQuest(const std::string& questId) {
QuestMutationResult result;
result.success = setStatus(questId, QuestStatus::Completed);
if (result.success)
result.events.push_back({QuestEventType::Completed, questId, {}});
return result;
}
bool QuestJournal::failQuest(const std::string& questId) {
bool changed = setStatus(questId, QuestStatus::Failed);
if (changed && onQuestFailed) onQuestFailed(questId);
return changed;
QuestMutationResult QuestJournal::failQuest(const std::string& questId) {
QuestMutationResult result;
result.success = setStatus(questId, QuestStatus::Failed);
if (result.success)
result.events.push_back({QuestEventType::Failed, questId, {}});
return result;
}
bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed) {
QuestMutationResult QuestJournal::setObjectiveCompleted(
const std::string& questId, const std::string& objectiveId, bool completed) {
QuestMutationResult result;
QuestState* quest = findQuest(questId);
if (!quest) return false;
if (!quest) return result;
for (auto& objective : quest->definition.objectives) {
if (objective.id == objectiveId) {
objective.completed = completed;
result.success = true;
if (completed) {
if (onObjectiveCompleted) onObjectiveCompleted(questId, objectiveId);
result.events.push_back(
{QuestEventType::ObjectiveCompleted, questId, objectiveId});
}
if (completed && quest->definition.autoComplete && quest->status == QuestStatus::Available) {
@@ -147,41 +157,48 @@ bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std::
[](const QuestObjective& o) { return o.completed; });
if (allDone) {
quest->status = QuestStatus::Completed;
if (onQuestCompleted) onQuestCompleted(questId);
result.events.push_back(
{QuestEventType::Completed, questId, {}});
}
}
return true;
return result;
}
}
return false;
return result;
}
bool QuestJournal::setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible) {
QuestMutationResult QuestJournal::setObjectiveVisible(
const std::string& questId, const std::string& objectiveId, bool visible) {
QuestMutationResult result;
QuestState* quest = findQuest(questId);
if (!quest) return false;
if (!quest) return result;
for (auto& objective : quest->definition.objectives) {
if (objective.id == objectiveId) {
objective.visible = visible;
return true;
result.success = true;
return result;
}
}
return false;
return result;
}
bool QuestJournal::setActiveObjective(const std::string& questId, int objectiveIndex) {
QuestMutationResult QuestJournal::setActiveObjective(
const std::string& questId, int objectiveIndex) {
QuestMutationResult result;
QuestState* quest = findQuest(questId);
if (!quest) return false;
if (!quest) return result;
if (objectiveIndex < 0 || objectiveIndex >= static_cast<int>(quest->definition.objectives.size())) {
return false;
return result;
}
quest->activeObjectiveIndex = objectiveIndex;
return true;
result.success = true;
return result;
}
QuestState* QuestJournal::findQuest(const std::string& questId) {
+24 -13
View File
@@ -6,10 +6,27 @@
#include <string>
#include <unordered_map>
#include <vector>
#include <functional>
namespace FRG::Quest {
enum class QuestEventType {
Unlocked,
Completed,
Failed,
ObjectiveCompleted
};
struct QuestEvent {
QuestEventType type;
std::string questId;
std::string objectiveId;
};
struct QuestMutationResult {
bool success = false;
std::vector<QuestEvent> events;
};
class QuestJournal : public FRG::ISaveable {
public:
bool loadFromFile(const std::string& path, const std::string& zipFile = "");
@@ -22,19 +39,13 @@ public:
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
// Event callbacks — set by the owner (MenuManager) to receive notifications.
std::function<void(const std::string& questId)> onQuestUnlocked;
std::function<void(const std::string& questId)> onQuestCompleted;
std::function<void(const std::string& questId)> onQuestFailed;
std::function<void(const std::string& questId, const std::string& objectiveId)> onObjectiveCompleted;
QuestMutationResult unlockQuest(const std::string& questId);
QuestMutationResult completeQuest(const std::string& questId);
QuestMutationResult failQuest(const std::string& questId);
bool unlockQuest(const std::string& questId);
bool completeQuest(const std::string& questId);
bool failQuest(const std::string& questId);
bool setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true);
bool setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible = true);
bool setActiveObjective(const std::string& questId, int objectiveIndex);
QuestMutationResult setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true);
QuestMutationResult setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible = true);
QuestMutationResult setActiveObjective(const std::string& questId, int objectiveIndex);
QuestState* findQuest(const std::string& questId);
const QuestState* findQuest(const std::string& questId) const;
+8 -6
View File
@@ -100,13 +100,15 @@ namespace FRG {
break;
}
auto state = player.stateSnapshot();
if (!state) return;
std::vector<Eigen::Vector3f> route;
route.reserve(player.state.pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(player.state.position.x(), 0.0f, player.state.position.z()));
route.reserve(state->pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(state->position.x(), 0.0f, state->position.z()));
const size_t firstWaypoint = (std::min)(
player.state.currentWaypointIndex, player.state.pathWaypoints.size());
for (size_t i = firstWaypoint; i < player.state.pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = player.state.pathWaypoints[i];
state->currentWaypointIndex, state->pathWaypoints.size());
for (size_t i = firstWaypoint; i < state->pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = state->pathWaypoints[i];
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
}
@@ -127,7 +129,7 @@ namespace FRG {
constexpr float kPlayerClearanceMs = 20.0f;
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
const float playerSpeed = (std::max)(
player.state.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
state->walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f;
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
const float waveSpeed = totalLength / (revealDurationMs / 1000.0f);
+78
View File
@@ -0,0 +1,78 @@
#include "Utils.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
namespace FRG {
std::string readTextFile(const std::string& filename)
{
std::ifstream file(filename, std::ios::binary);
if (!file) return {};
return std::string(
std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>());
}
std::vector<char> readFile(const std::string& filename)
{
std::ifstream file(filename, std::ios::binary | std::ios::ate);
if (!file) return {};
const std::streamsize size = file.tellg();
if (size <= 0) return {};
file.seekg(0, std::ios::beg);
std::vector<char> data(static_cast<std::size_t>(size));
if (!file.read(data.data(), size)) return {};
return data;
}
std::vector<char> readFileFromZIP(
const std::string& filename, const std::string& zipfilename)
{
if (zipfilename.empty()) return readFile(filename);
std::cerr << "ZIP resources are not enabled in the idle headless server: "
<< zipfilename << " (" << filename << ")\n";
return {};
}
bool zipFileExists(const std::string& zipfilename)
{
return false;
}
std::filesystem::path getSaveDirectory()
{
#if defined(_WIN32)
const char* base = std::getenv("APPDATA");
#else
const char* base = std::getenv("XDG_CONFIG_HOME");
if (!base || !*base) base = std::getenv("HOME");
#endif
const std::filesystem::path root =
base && *base ? std::filesystem::path(base)
: std::filesystem::current_path();
return root / "Fish Run Games" / "Shadow Over Bishkek Demo";
}
std::string readSavedTextFile(const std::string& filename)
{
return readTextFile((getSaveDirectory() / filename).string());
}
bool saveJsonToFile(
const nlohmann::json& root, const std::string& filename)
{
std::ofstream file(filename);
if (!file) return false;
file << root.dump(2);
return file.good();
}
std::ostream& logger()
{
return std::cout;
}
} // namespace FRG