Compare commits
7
Commits
main
...
networking
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23fef6f349 | ||
|
|
28c6e83eda | ||
|
|
075750442f | ||
|
|
931236bec0 | ||
|
|
297bd7377f | ||
|
|
5e9a663812 | ||
|
|
66e93d46f4 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ===========================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+319
-1200
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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--!")
|
||||
@@ -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
@@ -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!---!")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+69
-48
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+9
-24
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 = "");
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "render/Renderer.h"
|
||||
#include <Eigen/Core>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
+43
-26
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user