Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab9f300b6 | ||
|
|
28846b9f25 | ||
|
|
23fef6f349 | ||
|
|
28c6e83eda | ||
|
|
075750442f | ||
|
|
931236bec0 | ||
|
|
297bd7377f | ||
|
|
5e9a663812 | ||
|
|
66e93d46f4 |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
# Character Presentation Interpolation
|
||||||
|
|
||||||
|
The authoritative game simulation still advances in exact 100 ms ticks. Character
|
||||||
|
interpolation is a rendering-only feature and does not modify `CharacterState`,
|
||||||
|
network commands, tick IDs, state hashes, or deterministic lockstep behavior.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Each runtime `Character` keeps two presentation samples:
|
||||||
|
|
||||||
|
- `previous`: the visual values received at the preceding completed simulation tick;
|
||||||
|
- `current`: the visual values received at the latest completed simulation tick.
|
||||||
|
|
||||||
|
Rendering calculates an alpha from the real elapsed time since the latest completed
|
||||||
|
tick, clamped to the 100 ms simulation interval. It renders the delayed
|
||||||
|
`previous -> current` transition. This introduces at most one simulation tick of
|
||||||
|
presentation latency, but it avoids predicting a state that has not arrived from
|
||||||
|
the authoritative simulation.
|
||||||
|
|
||||||
|
The following values are interpolated:
|
||||||
|
|
||||||
|
- Position uses linear interpolation.
|
||||||
|
- Facing angle uses the shortest angular path, including wraparound at ±π.
|
||||||
|
- Animation frame remains fractional and is interpolated when both samples use
|
||||||
|
the same animation state. Looping clips treat the last frame to first frame
|
||||||
|
transition as a cyclic interval instead of interpolating backward through the
|
||||||
|
entire clip.
|
||||||
|
|
||||||
|
The interpolated values are used by mesh, weapon, shadow, night-lighting, name,
|
||||||
|
and health-bar rendering. The local camera and shadow-map center follow the same
|
||||||
|
interpolated player position; otherwise the camera would jump to the newest
|
||||||
|
authoritative position while the player model was still in its presentation
|
||||||
|
transition. Gameplay queries, commands, callbacks, collision, and presentation
|
||||||
|
events continue to use the authoritative snapshot.
|
||||||
|
|
||||||
|
## Other presentation animations
|
||||||
|
|
||||||
|
Teleport-zone star particles are advanced by `Location::updatePresentation()`
|
||||||
|
once per render frame using the real frame delta. They are no longer advanced
|
||||||
|
only from the 100 ms simulation-tick callback, so their visual motion remains
|
||||||
|
continuous without affecting teleport activation or other authoritative state.
|
||||||
|
|
||||||
|
Dialogue typewriter text and cutscene presentation timers use the same
|
||||||
|
per-render-frame path. This keeps character reveals, subtitle timing, fades, and
|
||||||
|
cutscene image poses smooth instead of stepping in 100 ms increments.
|
||||||
|
|
||||||
|
Interactive objects retain authoritative move, rotate, scale, and fade tasks on
|
||||||
|
the simulation tick. Their previous and current visual transforms are sampled
|
||||||
|
at tick boundaries and interpolated only while drawing, so doors and similar
|
||||||
|
objects remain logically deterministic while their presentation is smooth.
|
||||||
|
|
||||||
|
The same render-only sampling is used for `NpcCar` taxi visuals. Its position,
|
||||||
|
body rotation, wheel rotation, and steering angle are interpolated for drawing;
|
||||||
|
the authoritative taxi movement and waypoint logic remain fixed-step.
|
||||||
|
|
||||||
|
## Animation skinning
|
||||||
|
|
||||||
|
GPU and CPU bone interpolation receive the fractional render frame instead of an
|
||||||
|
integer-truncated frame. Bone positions are linearly interpolated and bone
|
||||||
|
rotations use spherical interpolation as before. Frames at the end of a clip are
|
||||||
|
clamped to the final keyframe.
|
||||||
|
|
||||||
|
## Discontinuities
|
||||||
|
|
||||||
|
The presentation buffer is reset instead of blended when a character is first
|
||||||
|
observed, changes animation state or enabled state, or has not received a tick
|
||||||
|
for a sufficiently long gap. This prevents stale positions, animation clips, or
|
||||||
|
disabled characters from producing visible interpolation artifacts.
|
||||||
|
|
||||||
|
## Local and networked games
|
||||||
|
|
||||||
|
Local games continue accumulating wall-clock remainder time and executing exact
|
||||||
|
100 ms simulation updates. Networked games continue consuming only contiguous
|
||||||
|
server tick messages. Both paths publish completed snapshots to the same runtime
|
||||||
|
presentation objects; interpolation only changes how those snapshots are drawn
|
||||||
|
between updates.
|
||||||
@@ -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/Location.cpp
|
||||||
../../../../src/LocationState.h
|
../../../../src/LocationState.h
|
||||||
../../../../src/LocationState.cpp
|
../../../../src/LocationState.cpp
|
||||||
|
../../../../src/LocationGameState.h
|
||||||
|
../../../../src/LocationGameState.cpp
|
||||||
../../../../src/LocationEditor.h
|
../../../../src/LocationEditor.h
|
||||||
../../../../src/LocationEditor.cpp
|
../../../../src/LocationEditor.cpp
|
||||||
../../../../src/NpcCar.h
|
../../../../src/NpcCar.h
|
||||||
@@ -104,6 +106,11 @@ add_library(main SHARED
|
|||||||
../../../../src/GameConstants.cpp
|
../../../../src/GameConstants.cpp
|
||||||
../../../../src/GameState.h
|
../../../../src/GameState.h
|
||||||
../../../../src/GameState.cpp
|
../../../../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.h
|
||||||
../../../../src/ScriptEngine.cpp
|
../../../../src/ScriptEngine.cpp
|
||||||
../../../../src/navigation/PathFinder.h
|
../../../../src/navigation/PathFinder.h
|
||||||
@@ -112,11 +119,13 @@ add_library(main SHARED
|
|||||||
../../../../src/items/GameObjectLoader.cpp
|
../../../../src/items/GameObjectLoader.cpp
|
||||||
../../../../src/items/Item.h
|
../../../../src/items/Item.h
|
||||||
../../../../src/items/Item.cpp
|
../../../../src/items/Item.cpp
|
||||||
|
../../../../src/items/Inventory.cpp
|
||||||
../../../../src/items/ItemRegistry.h
|
../../../../src/items/ItemRegistry.h
|
||||||
../../../../src/items/ItemRegistry.cpp
|
../../../../src/items/ItemRegistry.cpp
|
||||||
../../../../src/Campaign.cpp
|
../../../../src/Campaign.cpp
|
||||||
../../../../src/items/InteractiveObject.h
|
../../../../src/items/InteractiveObject.h
|
||||||
../../../../src/items/InteractiveObject.cpp
|
../../../../src/items/InteractiveObject.cpp
|
||||||
|
../../../../src/items/InteractiveObjectState.cpp
|
||||||
../../../../src/items/InteractiveObjectState.h
|
../../../../src/items/InteractiveObjectState.h
|
||||||
../../../../src/dialogue/DialogueTypes.h
|
../../../../src/dialogue/DialogueTypes.h
|
||||||
../../../../src/dialogue/DialogueDatabase.h
|
../../../../src/dialogue/DialogueDatabase.h
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ add_executable(ShadowOverBishkekDemo
|
|||||||
../src/Location.cpp
|
../src/Location.cpp
|
||||||
../src/LocationState.h
|
../src/LocationState.h
|
||||||
../src/LocationState.cpp
|
../src/LocationState.cpp
|
||||||
|
../src/LocationGameState.h
|
||||||
|
../src/LocationGameState.cpp
|
||||||
../src/LocationEditor.h
|
../src/LocationEditor.h
|
||||||
../src/LocationEditor.cpp
|
../src/LocationEditor.cpp
|
||||||
../src/NpcCar.h
|
../src/NpcCar.h
|
||||||
@@ -129,6 +131,15 @@ add_executable(ShadowOverBishkekDemo
|
|||||||
../src/GameConstants.cpp
|
../src/GameConstants.cpp
|
||||||
../src/GameState.h
|
../src/GameState.h
|
||||||
../src/GameState.cpp
|
../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.h
|
||||||
../src/ScriptEngine.cpp
|
../src/ScriptEngine.cpp
|
||||||
../src/navigation/PathFinder.h
|
../src/navigation/PathFinder.h
|
||||||
@@ -137,12 +148,14 @@ add_executable(ShadowOverBishkekDemo
|
|||||||
../src/items/GameObjectLoader.cpp
|
../src/items/GameObjectLoader.cpp
|
||||||
../src/items/Item.h
|
../src/items/Item.h
|
||||||
../src/items/Item.cpp
|
../src/items/Item.cpp
|
||||||
|
../src/items/Inventory.cpp
|
||||||
../src/items/ItemRegistry.h
|
../src/items/ItemRegistry.h
|
||||||
../src/items/ItemRegistry.cpp
|
../src/items/ItemRegistry.cpp
|
||||||
../src/Campaign.cpp
|
../src/Campaign.cpp
|
||||||
../src/items/InteractiveObject.h
|
../src/items/InteractiveObject.h
|
||||||
../src/items/InteractiveObject.cpp
|
../src/items/InteractiveObject.cpp
|
||||||
../src/items/InteractiveObjectState.h
|
../src/items/InteractiveObjectState.h
|
||||||
|
../src/items/InteractiveObjectState.cpp
|
||||||
../src/dialogue/DialogueTypes.h
|
../src/dialogue/DialogueTypes.h
|
||||||
../src/dialogue/DialogueDatabase.h
|
../src/dialogue/DialogueDatabase.h
|
||||||
../src/dialogue/DialogueDatabase.cpp
|
../src/dialogue/DialogueDatabase.cpp
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ set(SOURCES
|
|||||||
../src/Location.cpp
|
../src/Location.cpp
|
||||||
../src/LocationState.h
|
../src/LocationState.h
|
||||||
../src/LocationState.cpp
|
../src/LocationState.cpp
|
||||||
|
../src/LocationGameState.h
|
||||||
|
../src/LocationGameState.cpp
|
||||||
../src/LocationEditor.h
|
../src/LocationEditor.h
|
||||||
../src/LocationEditor.cpp
|
../src/LocationEditor.cpp
|
||||||
../src/NpcCar.h
|
../src/NpcCar.h
|
||||||
@@ -123,6 +125,11 @@ set(SOURCES
|
|||||||
../src/GameConstants.cpp
|
../src/GameConstants.cpp
|
||||||
../src/GameState.h
|
../src/GameState.h
|
||||||
../src/GameState.cpp
|
../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.h
|
||||||
../src/ScriptEngine.cpp
|
../src/ScriptEngine.cpp
|
||||||
../src/navigation/PathFinder.h
|
../src/navigation/PathFinder.h
|
||||||
@@ -131,11 +138,13 @@ set(SOURCES
|
|||||||
../src/items/GameObjectLoader.cpp
|
../src/items/GameObjectLoader.cpp
|
||||||
../src/items/Item.h
|
../src/items/Item.h
|
||||||
../src/items/Item.cpp
|
../src/items/Item.cpp
|
||||||
|
../src/items/Inventory.cpp
|
||||||
../src/items/ItemRegistry.h
|
../src/items/ItemRegistry.h
|
||||||
../src/items/ItemRegistry.cpp
|
../src/items/ItemRegistry.cpp
|
||||||
../src/Campaign.cpp
|
../src/Campaign.cpp
|
||||||
../src/items/InteractiveObject.h
|
../src/items/InteractiveObject.h
|
||||||
../src/items/InteractiveObject.cpp
|
../src/items/InteractiveObject.cpp
|
||||||
|
../src/items/InteractiveObjectState.cpp
|
||||||
../src/items/InteractiveObjectState.h
|
../src/items/InteractiveObjectState.h
|
||||||
../src/dialogue/DialogueTypes.h
|
../src/dialogue/DialogueTypes.h
|
||||||
../src/dialogue/DialogueDatabase.h
|
../src/dialogue/DialogueDatabase.h
|
||||||
@@ -193,7 +202,6 @@ set(EMSCRIPTEN_FLAGS
|
|||||||
#"-pthread"
|
#"-pthread"
|
||||||
#"-sUSE_PTHREADS=1"
|
#"-sUSE_PTHREADS=1"
|
||||||
"-fexceptions"
|
"-fexceptions"
|
||||||
"-DNETWORK"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2")
|
target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2")
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ add_executable(ShadowOverBishkekDemo WIN32
|
|||||||
../src/Location.cpp
|
../src/Location.cpp
|
||||||
../src/LocationState.h
|
../src/LocationState.h
|
||||||
../src/LocationState.cpp
|
../src/LocationState.cpp
|
||||||
|
../src/LocationGameState.h
|
||||||
|
../src/LocationGameState.cpp
|
||||||
../src/LocationEditor.h
|
../src/LocationEditor.h
|
||||||
../src/LocationEditor.cpp
|
../src/LocationEditor.cpp
|
||||||
../src/NpcCar.h
|
../src/NpcCar.h
|
||||||
@@ -71,6 +73,17 @@ add_executable(ShadowOverBishkekDemo WIN32
|
|||||||
../src/GameConstants.cpp
|
../src/GameConstants.cpp
|
||||||
../src/GameState.h
|
../src/GameState.h
|
||||||
../src/GameState.cpp
|
../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.h
|
||||||
../src/ScriptEngine.cpp
|
../src/ScriptEngine.cpp
|
||||||
../src/navigation/PathFinder.h
|
../src/navigation/PathFinder.h
|
||||||
@@ -79,12 +92,14 @@ add_executable(ShadowOverBishkekDemo WIN32
|
|||||||
../src/items/GameObjectLoader.cpp
|
../src/items/GameObjectLoader.cpp
|
||||||
../src/items/Item.h
|
../src/items/Item.h
|
||||||
../src/items/Item.cpp
|
../src/items/Item.cpp
|
||||||
|
../src/items/Inventory.cpp
|
||||||
../src/items/ItemRegistry.h
|
../src/items/ItemRegistry.h
|
||||||
../src/items/ItemRegistry.cpp
|
../src/items/ItemRegistry.cpp
|
||||||
../src/Campaign.cpp
|
../src/Campaign.cpp
|
||||||
../src/items/InteractiveObject.h
|
../src/items/InteractiveObject.h
|
||||||
../src/items/InteractiveObject.cpp
|
../src/items/InteractiveObject.cpp
|
||||||
../src/items/InteractiveObjectState.h
|
../src/items/InteractiveObjectState.h
|
||||||
|
../src/items/InteractiveObjectState.cpp
|
||||||
../src/dialogue/DialogueTypes.h
|
../src/dialogue/DialogueTypes.h
|
||||||
../src/dialogue/DialogueDatabase.h
|
../src/dialogue/DialogueDatabase.h
|
||||||
../src/dialogue/DialogueDatabase.cpp
|
../src/dialogue/DialogueDatabase.cpp
|
||||||
@@ -125,7 +140,9 @@ set_target_properties(ShadowOverBishkekDemo PROPERTIES
|
|||||||
# Определения препроцессора
|
# Определения препроцессора
|
||||||
target_compile_definitions(ShadowOverBishkekDemo PRIVATE
|
target_compile_definitions(ShadowOverBishkekDemo PRIVATE
|
||||||
WIN32_LEAN_AND_MEAN
|
WIN32_LEAN_AND_MEAN
|
||||||
|
_WIN32_WINNT=0x0A00
|
||||||
PNG_ENABLED
|
PNG_ENABLED
|
||||||
|
NETWORK
|
||||||
)
|
)
|
||||||
|
|
||||||
# Линкуем с SDL2main, если он вообще установлен
|
# Линкуем с SDL2main, если он вообще установлен
|
||||||
@@ -147,7 +164,16 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
|
|||||||
|
|
||||||
# Линкуем OpenGL (Windows)
|
# Линкуем OpenGL (Windows)
|
||||||
if(WIN32)
|
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()
|
endif()
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "battle_zone",
|
"id": "battle_zone",
|
||||||
"title": { "ru": "Зона боя", "en": "Battle Zone" },
|
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" },
|
||||||
"locations": [
|
"locations": [
|
||||||
"resources/config/locations/battle_zone_uni_exterior.json"
|
"resources/config/locations/battle_zone_uni_exterior.json"
|
||||||
],
|
],
|
||||||
@@ -27,6 +27,21 @@
|
|||||||
"quests": "resources/config/quests_battle_zone.json",
|
"quests": "resources/config/quests_battle_zone.json",
|
||||||
"tutorial": false,
|
"tutorial": false,
|
||||||
"loadingScreen": "resources/w/ui/screen_loading.json"
|
"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": [
|
"objects": [
|
||||||
{
|
{
|
||||||
|
"id": "WashingMachine002",
|
||||||
"name": "WashingMachine002",
|
"name": "WashingMachine002",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"activateFunction": "on_washing_machine1_click"
|
"activateFunction": "on_washing_machine1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Dryer002",
|
||||||
"name": "Dryer002",
|
"name": "Dryer002",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"activateFunction": "on_dryer1_click"
|
"activateFunction": "on_dryer1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Toilet001",
|
||||||
"name": "Toilet001",
|
"name": "Toilet001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
"activateFunction": "on_toilet_click"
|
"activateFunction": "on_toilet_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Sink002",
|
||||||
"name": "Sink002",
|
"name": "Sink002",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -105,6 +109,7 @@
|
|||||||
"activateFunction": "on_sink1_click"
|
"activateFunction": "on_sink1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Sink003",
|
||||||
"name": "Sink003",
|
"name": "Sink003",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -131,6 +136,7 @@
|
|||||||
"activateFunction": "on_sink1_click"
|
"activateFunction": "on_sink1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Sink004",
|
||||||
"name": "Sink004",
|
"name": "Sink004",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -157,6 +163,7 @@
|
|||||||
"activateFunction": "on_sink1_click"
|
"activateFunction": "on_sink1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Sink005",
|
||||||
"name": "Sink005",
|
"name": "Sink005",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -183,6 +190,7 @@
|
|||||||
"activateFunction": "on_sink1_click"
|
"activateFunction": "on_sink1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Shower001",
|
||||||
"name": "Shower001",
|
"name": "Shower001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -209,6 +217,7 @@
|
|||||||
"activateFunction": "on_shower_click"
|
"activateFunction": "on_shower_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Freezer001",
|
||||||
"name": "Freezer001",
|
"name": "Freezer001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -235,6 +244,7 @@
|
|||||||
"activateFunction": "on_freezer_click"
|
"activateFunction": "on_freezer_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Boombox001",
|
||||||
"name": "Boombox001",
|
"name": "Boombox001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -261,6 +271,7 @@
|
|||||||
"activateFunction": "on_boombox_click"
|
"activateFunction": "on_boombox_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "WashingMachine001",
|
||||||
"name": "WashingMachine001",
|
"name": "WashingMachine001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -287,6 +298,7 @@
|
|||||||
"activateFunction": "on_washing_machine1_click"
|
"activateFunction": "on_washing_machine1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Shelf001",
|
||||||
"name": "Shelf001",
|
"name": "Shelf001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -313,6 +325,7 @@
|
|||||||
"activateFunction": "on_shelf_click"
|
"activateFunction": "on_shelf_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Stove001",
|
||||||
"name": "Stove001",
|
"name": "Stove001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -339,6 +352,7 @@
|
|||||||
"activateFunction": "on_stove1_click"
|
"activateFunction": "on_stove1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Dryer001",
|
||||||
"name": "Dryer001",
|
"name": "Dryer001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -365,6 +379,7 @@
|
|||||||
"activateFunction": "on_dryer1_click"
|
"activateFunction": "on_dryer1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Sink001",
|
||||||
"name": "Sink001",
|
"name": "Sink001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -391,6 +406,7 @@
|
|||||||
"activateFunction": "on_sink1_click"
|
"activateFunction": "on_sink1_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_1_1_0_Leaf_001",
|
||||||
"name": "Door_Room_1_1_0_Leaf_001",
|
"name": "Door_Room_1_1_0_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_1_1_0_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_1_1_0_Leaf_001.txt.bin",
|
||||||
@@ -398,7 +414,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 10.0,
|
"positionZ": 10.0,
|
||||||
"approachRadius": 0.89,
|
"approachRadius": 0.89,
|
||||||
"boundsMaxX": 0.01552116870880127,
|
"boundsMaxX": 0.01552116870880127,
|
||||||
@@ -415,6 +431,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_1_-1_0_Leaf_001",
|
||||||
"name": "Door_Room_1_-1_0_Leaf_001",
|
"name": "Door_Room_1_-1_0_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_1_-1_0_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_1_-1_0_Leaf_001.txt.bin",
|
||||||
@@ -422,7 +439,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 10.0,
|
"positionZ": 10.0,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.10440409183502197,
|
"boundsMaxX": 0.10440409183502197,
|
||||||
@@ -439,6 +456,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_-1_1_0_Leaf_001",
|
||||||
"name": "Door_Room_-1_1_0_Leaf_001",
|
"name": "Door_Room_-1_1_0_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_-1_1_0_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_-1_1_0_Leaf_001.txt.bin",
|
||||||
@@ -446,7 +464,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -10.0,
|
"positionZ": -10.0,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.05766594409942627,
|
"boundsMaxX": 0.05766594409942627,
|
||||||
@@ -463,6 +481,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_-1_-1_0_Leaf_001",
|
||||||
"name": "Door_Room_-1_-1_0_Leaf_001",
|
"name": "Door_Room_-1_-1_0_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_0_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_0_Leaf_001.txt.bin",
|
||||||
@@ -470,7 +489,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -10.0,
|
"positionZ": -10.0,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.10906755924224854,
|
"boundsMaxX": 0.10906755924224854,
|
||||||
@@ -487,6 +506,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_1_1_1_Leaf_001",
|
||||||
"name": "Door_Room_1_1_1_Leaf_001",
|
"name": "Door_Room_1_1_1_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_1_1_1_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_1_1_1_Leaf_001.txt.bin",
|
||||||
@@ -494,7 +514,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 15.7,
|
"positionZ": 15.7,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.046350955963134766,
|
"boundsMaxX": 0.046350955963134766,
|
||||||
@@ -511,6 +531,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_1_-1_1_Leaf_001",
|
||||||
"name": "Door_Room_1_-1_1_Leaf_001",
|
"name": "Door_Room_1_-1_1_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_1_-1_1_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_1_-1_1_Leaf_001.txt.bin",
|
||||||
@@ -518,7 +539,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 15.7,
|
"positionZ": 15.7,
|
||||||
"approachRadius": 0.800000011920929,
|
"approachRadius": 0.800000011920929,
|
||||||
"boundsMaxX": 0.12420153617858887,
|
"boundsMaxX": 0.12420153617858887,
|
||||||
@@ -535,6 +556,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Utility_1_1_2_Leaf_001",
|
||||||
"name": "Door_Utility_1_1_2_Leaf_001",
|
"name": "Door_Utility_1_1_2_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Utility_1_1_2_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Utility_1_1_2_Leaf_001.txt.bin",
|
||||||
@@ -542,7 +564,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 19.95,
|
"positionZ": 19.95,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": -0.050987482070922852,
|
"boundsMaxX": -0.050987482070922852,
|
||||||
@@ -559,6 +581,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Utility_1_-1_2_Leaf_001",
|
||||||
"name": "Door_Utility_1_-1_2_Leaf_001",
|
"name": "Door_Utility_1_-1_2_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Utility_1_-1_2_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Utility_1_-1_2_Leaf_001.txt.bin",
|
||||||
@@ -566,7 +589,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": 19.95,
|
"positionZ": 19.95,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.073349356651306152,
|
"boundsMaxX": 0.073349356651306152,
|
||||||
@@ -583,6 +606,7 @@
|
|||||||
"activateFunction": "on_locked_door_click"
|
"activateFunction": "on_locked_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Utility_-1_1_2_Leaf_001",
|
||||||
"name": "Door_Utility_-1_1_2_Leaf_001",
|
"name": "Door_Utility_-1_1_2_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Utility_-1_1_2_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Utility_-1_1_2_Leaf_001.txt.bin",
|
||||||
@@ -590,7 +614,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -19.95,
|
"positionZ": -19.95,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -599,6 +623,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Utility_-1_-1_2_Leaf_001",
|
||||||
"name": "Door_Utility_-1_-1_2_Leaf_001",
|
"name": "Door_Utility_-1_-1_2_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Utility_-1_-1_2_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Utility_-1_-1_2_Leaf_001.txt.bin",
|
||||||
@@ -606,7 +631,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -19.95,
|
"positionZ": -19.95,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -615,13 +640,14 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Phone001",
|
||||||
"name": "Phone001",
|
"name": "Phone001",
|
||||||
"texturePath": "resources/w/dorm2/phone001_tex001.png",
|
"texturePath": "resources/w/dorm2/phone001_tex001.png",
|
||||||
"meshPath": "resources/w/dorm2/phone001_001.txt.bin",
|
"meshPath": "resources/w/dorm2/phone001_001.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 6.6644,
|
"positionX": 6.6644004991889,
|
||||||
"positionY": 0.9,
|
"positionY": 0.9,
|
||||||
"positionZ": -12.5262,
|
"positionZ": -12.5262,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
@@ -640,6 +666,7 @@
|
|||||||
"activateFunction": "on_phone_pickup"
|
"activateFunction": "on_phone_pickup"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Journal001",
|
||||||
"name": "Journal001",
|
"name": "Journal001",
|
||||||
"texturePath": "resources/w/dorm2/Book_Cover_tex002.png",
|
"texturePath": "resources/w/dorm2/Book_Cover_tex002.png",
|
||||||
"meshPath": "resources/w/dorm2/journal002_002.txt.bin",
|
"meshPath": "resources/w/dorm2/journal002_002.txt.bin",
|
||||||
@@ -647,7 +674,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 5.84296,
|
"positionX": 5.84296,
|
||||||
"positionY": 0.9,
|
"positionY": 0.9284919999539852,
|
||||||
"positionZ": -12.4661,
|
"positionZ": -12.4661,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"boundsMaxX": 0.17081403732299805,
|
"boundsMaxX": 0.17081403732299805,
|
||||||
@@ -665,6 +692,7 @@
|
|||||||
"activateFunction": "on_journal_pickup"
|
"activateFunction": "on_journal_pickup"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Bed001",
|
||||||
"name": "Bed001",
|
"name": "Bed001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -691,21 +719,23 @@
|
|||||||
"activateFunction": "on_bed_sleep"
|
"activateFunction": "on_bed_sleep"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Music001",
|
||||||
"name": "Music001",
|
"name": "Music001",
|
||||||
"texturePath": "resources/w/exterior/texAtlas002.png",
|
"texturePath": "resources/w/exterior/texAtlas002.png",
|
||||||
"meshPath": "resources/w/dorm2/D_Music_004.txt.bin",
|
"meshPath": "resources/w/dorm2/D_Music_004.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 5.10067,
|
"positionX": 5.1006695008111,
|
||||||
"positionY": 0.91385,
|
"positionY": 1.0181855017900467,
|
||||||
"positionZ": -11.9566,
|
"positionZ": -11.958324999397993,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.3,
|
"interactionRadius": 0.3,
|
||||||
"activateFunction": ""
|
"activateFunction": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Bath_-1_1_1_Leaf_002",
|
||||||
"name": "Door_Bath_-1_1_1_Leaf_002",
|
"name": "Door_Bath_-1_1_1_Leaf_002",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Bath_-1_1_1_Leaf_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Bath_-1_1_1_Leaf_002.txt.bin",
|
||||||
@@ -713,7 +743,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 2.825,
|
"positionX": 2.825,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -14.3,
|
"positionZ": -14.3,
|
||||||
"pivotX": -0.6,
|
"pivotX": -0.6,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -733,6 +763,7 @@
|
|||||||
"activateFunction": "on_player_bathroom_click"
|
"activateFunction": "on_player_bathroom_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Bath_-1_-1_1_Leaf_002",
|
||||||
"name": "Door_Bath_-1_-1_1_Leaf_002",
|
"name": "Door_Bath_-1_-1_1_Leaf_002",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Bath_-1_-1_1_Leaf_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Bath_-1_-1_1_Leaf_002.txt.bin",
|
||||||
@@ -740,7 +771,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -2.825,
|
"positionX": -2.825,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -14.3,
|
"positionZ": -14.3,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.55439233779907227,
|
"boundsMaxX": 0.55439233779907227,
|
||||||
@@ -757,6 +788,7 @@
|
|||||||
"activateFunction": "on_alik_bathroom_click"
|
"activateFunction": "on_alik_bathroom_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_-1_1_1_Leaf_001",
|
||||||
"name": "Door_Room_-1_1_1_Leaf_001",
|
"name": "Door_Room_-1_1_1_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_-1_1_1_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_-1_1_1_Leaf_001.txt.bin",
|
||||||
@@ -764,7 +796,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.1,
|
"positionX": 1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -15.7,
|
"positionZ": -15.7,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -784,6 +816,7 @@
|
|||||||
"activateFunction": "on_player_door_click"
|
"activateFunction": "on_player_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Door_Room_-1_-1_1_Leaf_001",
|
||||||
"name": "Door_Room_-1_-1_1_Leaf_001",
|
"name": "Door_Room_-1_-1_1_Leaf_001",
|
||||||
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
|
||||||
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_1_Leaf_001.txt.bin",
|
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_1_Leaf_001.txt.bin",
|
||||||
@@ -791,7 +824,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.1,
|
"positionX": -1.1,
|
||||||
"positionY": 0.0,
|
"positionY": 0.9695000052452087,
|
||||||
"positionZ": -15.7,
|
"positionZ": -15.7,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.13942182064056396,
|
"boundsMaxX": 0.13942182064056396,
|
||||||
@@ -811,6 +844,7 @@
|
|||||||
"activateFunction": "on_alik_door_click"
|
"activateFunction": "on_alik_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Bath_W_N_2_001",
|
||||||
"name": "Room_Cover_Bath_W_N_2_001",
|
"name": "Room_Cover_Bath_W_N_2_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_Bath_W_N_2_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_Bath_W_N_2_002.txt.bin",
|
||||||
@@ -818,13 +852,14 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 2.8249999284744263,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": -12.849999904632568,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_LivingRoom_W_N_2_001",
|
||||||
"name": "Room_Cover_LivingRoom_W_N_2_001",
|
"name": "Room_Cover_LivingRoom_W_N_2_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_N_2_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_N_2_002.txt.bin",
|
||||||
@@ -832,13 +867,14 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 4.949999928474426,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": -14.25,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_LivingRoom_W_S_2_001",
|
||||||
"name": "Room_Cover_LivingRoom_W_S_2_001",
|
"name": "Room_Cover_LivingRoom_W_S_2_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_S_2_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_S_2_002.txt.bin",
|
||||||
@@ -846,13 +882,14 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": -4.949999928474426,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": -14.25,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Main_Hall_And_Corridors_002",
|
||||||
"name": "Room_Cover_Main_Hall_And_Corridors_002",
|
"name": "Room_Cover_Main_Hall_And_Corridors_002",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_Main_Hall_And_Corridors_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_Main_Hall_And_Corridors_002.txt.bin",
|
||||||
@@ -861,12 +898,13 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": 0.0,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Others_001",
|
||||||
"name": "Room_Cover_Others_001",
|
"name": "Room_Cover_Others_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_All_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_All_002.txt.bin",
|
||||||
@@ -875,12 +913,13 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": 4.250000476837158,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Utility_W_N_3_001",
|
||||||
"name": "Room_Cover_Utility_W_N_3_001",
|
"name": "Room_Cover_Utility_W_N_3_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_N_3_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_N_3_002.txt.bin",
|
||||||
@@ -888,13 +927,14 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 4.949999928474426,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": -19.950000762939453,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Utility_W_S_3_001",
|
||||||
"name": "Room_Cover_Utility_W_S_3_001",
|
"name": "Room_Cover_Utility_W_S_3_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_S_3_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_S_3_002.txt.bin",
|
||||||
@@ -902,13 +942,14 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": -4.949999928474426,
|
||||||
"positionY": -0.1,
|
"positionY": 2.8500000476837157,
|
||||||
"positionZ": 0.0,
|
"positionZ": -19.950000762939453,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Roof",
|
||||||
"name": "Roof",
|
"name": "Roof",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/dorm2/Shadow_Roof001_002.txt.bin",
|
"meshPath": "resources/w/dorm2/Shadow_Roof001_002.txt.bin",
|
||||||
@@ -919,7 +960,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": 0.1,
|
"positionY": 3.2050000190734864,
|
||||||
"positionZ": 0.0,
|
"positionZ": 0.0,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"objects": [
|
"objects": [
|
||||||
{
|
{
|
||||||
|
"id": "Container001",
|
||||||
"name": "Container001",
|
"name": "Container001",
|
||||||
"texturePath": "resources/w/exterior/derbis001_tex001.png",
|
"texturePath": "resources/w/exterior/derbis001_tex001.png",
|
||||||
"meshPath": "resources/w/exterior/Derbis_001.txt.bin",
|
"meshPath": "resources/w/exterior/Derbis_001.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -26.8049,
|
"positionX": -27.003390023612976,
|
||||||
"positionY": 0.0,
|
"positionY": 0.32889999728649855,
|
||||||
"positionZ": 9.11524,
|
"positionZ": 8.820399544944763,
|
||||||
"approachRadius": 1.5,
|
"approachRadius": 1.5,
|
||||||
"boundsMaxX": 2.9518299102783203,
|
"boundsMaxX": 2.9518299102783203,
|
||||||
"boundsMaxY": 0.5,
|
"boundsMaxY": 0.5,
|
||||||
@@ -25,15 +26,16 @@
|
|||||||
"activateFunction": "on_container_clicked"
|
"activateFunction": "on_container_clicked"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "car001",
|
||||||
"name": "car001",
|
"name": "car001",
|
||||||
"texturePath": "resources/e/car4096blue_v002.png",
|
"texturePath": "resources/e/car4096blue_v002.png",
|
||||||
"meshPath": "resources/e/car004.txt.bin",
|
"meshPath": "resources/e/car004.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 90.0,
|
"rotationY": 90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -10.5,
|
"positionX": -10.503284412622452,
|
||||||
"positionY": 0.55,
|
"positionY": 0.9012498408555984,
|
||||||
"positionZ": -14.4195,
|
"positionZ": -14.861633367061614,
|
||||||
"approachRadius": 0.5,
|
"approachRadius": 0.5,
|
||||||
"boundsMaxX": 0.8858480453491211,
|
"boundsMaxX": 0.8858480453491211,
|
||||||
"boundsMaxY": 0.7,
|
"boundsMaxY": 0.7,
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"scale": 0.7
|
"scale": 0.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "car001wheels",
|
||||||
"name": "car001wheels",
|
"name": "car001wheels",
|
||||||
"texturePath": "resources/w/exterior/Wheel_base003.png",
|
"texturePath": "resources/w/exterior/Wheel_base003.png",
|
||||||
"meshPath": "resources/w/exterior/wheels002.txt.bin",
|
"meshPath": "resources/w/exterior/wheels002.txt.bin",
|
||||||
@@ -56,8 +59,8 @@
|
|||||||
"rotationY": 90.0,
|
"rotationY": 90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -10.5,
|
"positionX": -10.5,
|
||||||
"positionY": 0.0,
|
"positionY": 0.3359999924898147,
|
||||||
"positionZ": -14.4195,
|
"positionZ": -14.839499933242797,
|
||||||
"approachRadius": 0.5,
|
"approachRadius": 0.5,
|
||||||
"boundsMaxX": 0,
|
"boundsMaxX": 0,
|
||||||
"boundsMaxY": 0.0,
|
"boundsMaxY": 0.0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"objects": [
|
"objects": [
|
||||||
{
|
{
|
||||||
|
"id": "Bookshelf002",
|
||||||
"name": "Bookshelf002",
|
"name": "Bookshelf002",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Bookshelf003",
|
||||||
"name": "Bookshelf003",
|
"name": "Bookshelf003",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Bookshelf004",
|
||||||
"name": "Bookshelf004",
|
"name": "Bookshelf004",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Bookshelf005",
|
||||||
"name": "Bookshelf005",
|
"name": "Bookshelf005",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -105,15 +109,16 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Cake001",
|
||||||
"name": "Cake001",
|
"name": "Cake001",
|
||||||
"texturePath": "resources/w/exterior/texAtlas002.png",
|
"texturePath": "resources/w/exterior/texAtlas002.png",
|
||||||
"meshPath": "resources/w/interior/D_Cake_005.txt.bin",
|
"meshPath": "resources/w/interior/D_Cake_005.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0,
|
"positionX": -5.365539073944092,
|
||||||
"positionY": -5.0,
|
"positionY": 0.9888315200805664,
|
||||||
"positionZ": 0,
|
"positionZ": 2.468366503715515,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"boundsMaxX": 0.19605731964111328,
|
"boundsMaxX": 0.19605731964111328,
|
||||||
"boundsMaxY": 0.4,
|
"boundsMaxY": 0.4,
|
||||||
@@ -131,6 +136,7 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Computer001",
|
||||||
"name": "Computer001",
|
"name": "Computer001",
|
||||||
"texturePath": "resources/w/interior/computer_texture002.png",
|
"texturePath": "resources/w/interior/computer_texture002.png",
|
||||||
"textureDarkandsPath": "resources/w/interior/computer_texture002_dark.png",
|
"textureDarkandsPath": "resources/w/interior/computer_texture002_dark.png",
|
||||||
@@ -138,9 +144,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 5.0916,
|
"positionX": 5.01753252658844,
|
||||||
"positionY": 1.1534,
|
"positionY": 1.353411484324932,
|
||||||
"positionZ": 0.751629,
|
"positionZ": 0.6829135021486282,
|
||||||
"approachRadius": 1.2,
|
"approachRadius": 1.2,
|
||||||
"boundsMaxX": 0.6253643035888672,
|
"boundsMaxX": 0.6253643035888672,
|
||||||
"boundsMaxY": 0.5,
|
"boundsMaxY": 0.5,
|
||||||
@@ -155,15 +161,16 @@
|
|||||||
"activateFunction": "on_computer_clicked"
|
"activateFunction": "on_computer_clicked"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Knife001",
|
||||||
"name": "Knife001",
|
"name": "Knife001",
|
||||||
"texturePath": "resources/w/gg/Props.png",
|
"texturePath": "resources/w/gg/Props.png",
|
||||||
"meshPath": "resources/w/interior/Knife_lay001_004.txt.bin",
|
"meshPath": "resources/w/interior/Knife_lay001_004.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -4.8327,
|
"positionX": -4.959765496635623,
|
||||||
"positionY": 0.93551,
|
"positionY": 0.93551,
|
||||||
"positionZ": 2.2418,
|
"positionZ": 2.369899506953731,
|
||||||
"approachRadius": 1.4,
|
"approachRadius": 1.4,
|
||||||
"boundsMaxX": 0.30867958068847656,
|
"boundsMaxX": 0.30867958068847656,
|
||||||
"boundsMaxY": 0.4,
|
"boundsMaxY": 0.4,
|
||||||
@@ -177,6 +184,7 @@
|
|||||||
"castShadowNight" : false
|
"castShadowNight" : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Book001",
|
||||||
"name": "Book001",
|
"name": "Book001",
|
||||||
"texturePath": "resources/w/interior/Book_Cover_tex003.png",
|
"texturePath": "resources/w/interior/Book_Cover_tex003.png",
|
||||||
"meshPath": "resources/w/interior/book002_002.txt.bin",
|
"meshPath": "resources/w/interior/book002_002.txt.bin",
|
||||||
@@ -184,7 +192,7 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.84795,
|
"positionX": 1.84795,
|
||||||
"positionY": 0.6595,
|
"positionY": 0.6809705001562834,
|
||||||
"positionZ": 2.90552,
|
"positionZ": 2.90552,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"boundsMaxX": 0.2350165843963623,
|
"boundsMaxX": 0.2350165843963623,
|
||||||
@@ -202,6 +210,7 @@
|
|||||||
"castShadowNight" : false
|
"castShadowNight" : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Bookshelf001",
|
||||||
"name": "Bookshelf001",
|
"name": "Bookshelf001",
|
||||||
"texturePath": "resources/transparent.png",
|
"texturePath": "resources/transparent.png",
|
||||||
"meshPath": "resources/w/Cube_001.txt.bin",
|
"meshPath": "resources/w/Cube_001.txt.bin",
|
||||||
@@ -228,6 +237,7 @@
|
|||||||
"castShadow": false
|
"castShadow": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "ReportCard001",
|
||||||
"name": "ReportCard001",
|
"name": "ReportCard001",
|
||||||
"texturePath": "resources/w/exterior/texAtlas002.png",
|
"texturePath": "resources/w/exterior/texAtlas002.png",
|
||||||
"meshPath": "resources/w/interior/D_report_cardGG_005.txt.bin",
|
"meshPath": "resources/w/interior/D_report_cardGG_005.txt.bin",
|
||||||
@@ -252,6 +262,7 @@
|
|||||||
"activateFunction": "on_report_card_pickup"
|
"activateFunction": "on_report_card_pickup"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Note001",
|
||||||
"name": "Note001",
|
"name": "Note001",
|
||||||
"texturePath": "resources/w/interior/note001_tex003.png",
|
"texturePath": "resources/w/interior/note001_tex003.png",
|
||||||
"meshPath": "resources/w/interior/note001_002.txt.bin",
|
"meshPath": "resources/w/interior/note001_002.txt.bin",
|
||||||
@@ -276,15 +287,16 @@
|
|||||||
"activateFunction": "on_note_pickup"
|
"activateFunction": "on_note_pickup"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "car001",
|
||||||
"name": "car001",
|
"name": "car001",
|
||||||
"texturePath": "resources/e/car4096blue_v002.png",
|
"texturePath": "resources/e/car4096blue_v002.png",
|
||||||
"meshPath": "resources/e/car004.txt.bin",
|
"meshPath": "resources/e/car004.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 90.0,
|
"rotationY": 90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -10.5,
|
"positionX": -10.503284412622452,
|
||||||
"positionY": -5.0,
|
"positionY": -4.648750159144401,
|
||||||
"positionZ": -14.4195,
|
"positionZ": -14.861633367061614,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"boundsMaxX": 0.0,
|
"boundsMaxX": 0.0,
|
||||||
"boundsMaxY": 0.0,
|
"boundsMaxY": 0.0,
|
||||||
@@ -299,6 +311,7 @@
|
|||||||
"scale": 0.7
|
"scale": 0.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "car001wheels",
|
||||||
"name": "car001wheels",
|
"name": "car001wheels",
|
||||||
"texturePath": "resources/w/exterior/Wheel_base003.png",
|
"texturePath": "resources/w/exterior/Wheel_base003.png",
|
||||||
"meshPath": "resources/w/exterior/wheels002.txt.bin",
|
"meshPath": "resources/w/exterior/wheels002.txt.bin",
|
||||||
@@ -306,8 +319,8 @@
|
|||||||
"rotationY": 90.0,
|
"rotationY": 90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -10.5,
|
"positionX": -10.5,
|
||||||
"positionY": -5.0,
|
"positionY": -4.664000007510185,
|
||||||
"positionZ": -14.4195,
|
"positionZ": -14.839499933242797,
|
||||||
"approachRadius": 0.6,
|
"approachRadius": 0.6,
|
||||||
"boundsMaxX": 0.0,
|
"boundsMaxX": 0.0,
|
||||||
"boundsMaxY": 0.0,
|
"boundsMaxY": 0.0,
|
||||||
@@ -322,6 +335,7 @@
|
|||||||
"scale": 0.7
|
"scale": 0.7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_N_2_Leaf001",
|
||||||
"name": "Room_N_2_Leaf001",
|
"name": "Room_N_2_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputRoom_N_2_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputRoom_N_2_Leaf001.txt.bin",
|
||||||
@@ -330,7 +344,7 @@
|
|||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.5,
|
"positionX": 1.5,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": 6.965,
|
"positionZ": 6.400000002384186,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.06785714626312256,
|
"boundsMaxX": 0.06785714626312256,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -349,6 +363,7 @@
|
|||||||
"activateFunction": "on_library_door_click"
|
"activateFunction": "on_library_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_N_1_Leaf001",
|
||||||
"name": "Room_N_1_Leaf001",
|
"name": "Room_N_1_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputRoom_N_1_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputRoom_N_1_Leaf001.txt.bin",
|
||||||
@@ -357,7 +372,7 @@
|
|||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.5,
|
"positionX": 1.5,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": -1.035,
|
"positionZ": -1.5999999976158141,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.10772454738616943,
|
"boundsMaxX": 0.10772454738616943,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -376,6 +391,7 @@
|
|||||||
"activateFunction": "on_n2_door_click"
|
"activateFunction": "on_n2_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_S_1_Leaf001",
|
||||||
"name": "Room_S_1_Leaf001",
|
"name": "Room_S_1_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputRoom_S_1_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputRoom_S_1_Leaf001.txt.bin",
|
||||||
@@ -384,7 +400,7 @@
|
|||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.5,
|
"positionX": -1.5,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": -1.035,
|
"positionZ": -1.5999999976158141,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.12115073204040527,
|
"boundsMaxX": 0.12115073204040527,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -403,6 +419,7 @@
|
|||||||
"activateFunction": "on_s2_door_click"
|
"activateFunction": "on_s2_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_S_2_Leaf001",
|
||||||
"name": "Room_S_2_Leaf001",
|
"name": "Room_S_2_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputRoom_S_2_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputRoom_S_2_Leaf001.txt.bin",
|
||||||
@@ -411,7 +428,7 @@
|
|||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.5,
|
"positionX": -1.5,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": 6.965,
|
"positionZ": 6.400000002384186,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.09747374057769775,
|
"boundsMaxX": 0.09747374057769775,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -430,15 +447,16 @@
|
|||||||
"activateFunction": "on_teachers_door_click"
|
"activateFunction": "on_teachers_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Hall_Leaf001",
|
||||||
"name": "Hall_Leaf001",
|
"name": "Hall_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputHall_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputHall_Leaf001.txt.bin",
|
||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.565,
|
"positionX": 0.0,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": 8,
|
"positionZ": 8.000001000240445,
|
||||||
"approachRadius": 0.9,
|
"approachRadius": 0.9,
|
||||||
"boundsMaxX": 0.6243956089019775,
|
"boundsMaxX": 0.6243956089019775,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -457,6 +475,7 @@
|
|||||||
"activateFunction": "on_hall_door_click"
|
"activateFunction": "on_hall_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_S_0_Leaf001",
|
||||||
"name": "Room_S_0_Leaf001",
|
"name": "Room_S_0_Leaf001",
|
||||||
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
|
||||||
"meshPath": "resources/w/interior/outputRoom_S_0_Leaf001.txt.bin",
|
"meshPath": "resources/w/interior/outputRoom_S_0_Leaf001.txt.bin",
|
||||||
@@ -465,7 +484,7 @@
|
|||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.5,
|
"positionX": -1.5,
|
||||||
"positionY": 0.975,
|
"positionY": 0.975,
|
||||||
"positionZ": -9.035,
|
"positionZ": -9.599999020778657,
|
||||||
"approachRadius": 0.8,
|
"approachRadius": 0.8,
|
||||||
"boundsMaxX": 0.10914039611816406,
|
"boundsMaxX": 0.10914039611816406,
|
||||||
"boundsMaxY": 1.0,
|
"boundsMaxY": 1.0,
|
||||||
@@ -484,6 +503,7 @@
|
|||||||
"activateFunction": "on_s1_door_click"
|
"activateFunction": "on_s1_door_click"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Corridor_001",
|
||||||
"name": "Room_Cover_Corridor_001",
|
"name": "Room_Cover_Corridor_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_Corridor_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_Corridor_003.txt.bin",
|
||||||
@@ -491,9 +511,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 2.2500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": -3.999999761581421,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -501,6 +521,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_Main_Hall_001",
|
||||||
"name": "Room_Cover_Main_Hall_001",
|
"name": "Room_Cover_Main_Hall_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_Main_Hall_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_Main_Hall_003.txt.bin",
|
||||||
@@ -509,8 +530,8 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": 12.0,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -518,6 +539,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_South_1_001",
|
||||||
"name": "Room_Cover_South_1_001",
|
"name": "Room_Cover_South_1_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_South_1_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_South_1_003.txt.bin",
|
||||||
@@ -525,9 +547,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": -3.7500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": -12.0,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -535,6 +557,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_North_2_001",
|
||||||
"name": "Room_Cover_North_2_001",
|
"name": "Room_Cover_North_2_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_North_2_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_North_2_003.txt.bin",
|
||||||
@@ -542,9 +565,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 3.7500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": -4.000000048428774,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -552,6 +575,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_South_2_001",
|
||||||
"name": "Room_Cover_South_2_001",
|
"name": "Room_Cover_South_2_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_South_2_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_South_2_003.txt.bin",
|
||||||
@@ -559,9 +583,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": -3.7500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": -4.000000048428774,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -569,6 +593,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_North_3_001",
|
||||||
"name": "Room_Cover_North_3_001",
|
"name": "Room_Cover_North_3_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_North_3_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_North_3_003.txt.bin",
|
||||||
@@ -576,9 +601,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 3.7500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": 4.000000048428774,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
@@ -586,6 +611,7 @@
|
|||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "Room_Cover_South_3_001",
|
||||||
"name": "Room_Cover_South_3_001",
|
"name": "Room_Cover_South_3_001",
|
||||||
"texturePath": "resources/black.png",
|
"texturePath": "resources/black.png",
|
||||||
"meshPath": "resources/w/interior/Room_Cover_South_3_003.txt.bin",
|
"meshPath": "resources/w/interior/Room_Cover_South_3_003.txt.bin",
|
||||||
@@ -593,9 +619,9 @@
|
|||||||
"rotationX": 0.0,
|
"rotationX": 0.0,
|
||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": -3.7500000596046448,
|
||||||
"positionY": -5.0,
|
"positionY": 2.4499998092651367,
|
||||||
"positionZ": 0.0,
|
"positionZ": 4.000000048428774,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"pivotX": 0.0,
|
"pivotX": 0.0,
|
||||||
"pivotY": 0.0,
|
"pivotY": 0.0,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
"canAttack": true
|
"canAttack": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "npc_02_woman",
|
"id": "npc_altynai",
|
||||||
"name": "Алтынай",
|
"name": "Алтынай",
|
||||||
"animationIdlePath": "resources/w/girl/girl_idle001_small.anim",
|
"animationIdlePath": "resources/w/girl/girl_idle001_small.anim",
|
||||||
"animationWalkPath": "resources/w/girl/girl_walk010_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",
|
"localState": {
|
||||||
"chatHistory": [
|
"locations": {
|
||||||
[],
|
"uni_exterior": {
|
||||||
[],
|
|
||||||
[]
|
|
||||||
],
|
|
||||||
"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,
|
|
||||||
"cameraAzimuth": -2.3499999046325684,
|
"cameraAzimuth": -2.3499999046325684,
|
||||||
"cameraInclination": 1.103600025177002,
|
"cameraInclination": 1.103600025177002
|
||||||
"isDarklands": false,
|
}
|
||||||
"isDawn": false,
|
}
|
||||||
"isNight": false,
|
},
|
||||||
"targetInteractNpcIndex": -1,
|
"networkState": {
|
||||||
"targetInteractiveObjectIndex": -1,
|
"campaignId": "battle_zone",
|
||||||
"targetTeleportZoneIndex": -1,
|
"chatHistory": [
|
||||||
"taxi": {
|
[],
|
||||||
"taxiArrivedCallbackCalled": false,
|
[],
|
||||||
"taxiCar": {
|
[]
|
||||||
"currentWaypoint": 0,
|
],
|
||||||
"mode": 1,
|
"chatPreviewMsg": [
|
||||||
"positionX": -100.0,
|
"",
|
||||||
"positionY": -100.0,
|
"",
|
||||||
"positionZ": -100.0,
|
""
|
||||||
"rotation": 0.0,
|
],
|
||||||
"steeringAngle": 0.0,
|
"chatUnread": [
|
||||||
"velocity": 0.0,
|
true,
|
||||||
"waypointReachRadius": 3.0,
|
true,
|
||||||
"waypoints": []
|
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": {
|
"intBin_Plastic": {
|
||||||
"currentWaypoint": 0,
|
"alpha": 1.0,
|
||||||
"mode": 1,
|
"isActive": true,
|
||||||
"positionX": -100.0,
|
"isAnimating": false,
|
||||||
"positionY": -100.0,
|
"positionX": 1.399999976158142,
|
||||||
"positionZ": -100.0,
|
"positionY": 0.45736101269721985,
|
||||||
"rotation": 0.0,
|
"positionZ": 3.8499999046325684,
|
||||||
"steeringAngle": 0.0,
|
"rotationY": 0.0,
|
||||||
"velocity": 0.0,
|
"scale": 1.0
|
||||||
"waypointReachRadius": 3.0,
|
|
||||||
"waypoints": []
|
|
||||||
},
|
},
|
||||||
"taxiCarTriggerPositionX": 0.0,
|
"intBin_Waste_A": {
|
||||||
"taxiCarTriggerPositionY": 0.0,
|
"alpha": 1.0,
|
||||||
"taxiCarTriggerPositionZ": 0.0,
|
"isActive": true,
|
||||||
"taxiDefaultWaypoints": []
|
"isAnimating": false,
|
||||||
}
|
"positionX": 2.200000047683716,
|
||||||
},
|
"positionY": 0.45736101269721985,
|
||||||
"npcStates": [],
|
"positionZ": 3.8499999046325684,
|
||||||
"playerState": {
|
"rotationY": 0.0,
|
||||||
"attackTargetIndex": -1,
|
"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,
|
"attack_cooldown": 0.0,
|
||||||
"battle_state": 0,
|
"battle_state": 0,
|
||||||
"currentState": 0,
|
"currentState": 0,
|
||||||
"currentWaypointIndex": 0,
|
"currentWaypointIndex": 0,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"faceTargetIndex": -1,
|
"faceTargetId": "",
|
||||||
"facingAngle": 0.0,
|
"facingAngle": 0.0,
|
||||||
"homeDriftCheckTimer": 5.0,
|
"homeDriftCheckTimer": 5.0,
|
||||||
"homePosition": [
|
"homePosition": [
|
||||||
@@ -93,33 +174,79 @@
|
|||||||
"hp": 200.0,
|
"hp": 200.0,
|
||||||
"pathWaypoints": [],
|
"pathWaypoints": [],
|
||||||
"position": [
|
"position": [
|
||||||
-6, 0, 12.8
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
],
|
],
|
||||||
"requestedWalkTarget": [
|
"requestedWalkTarget": [
|
||||||
-6, 0, 12.8
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
],
|
],
|
||||||
"showWeapon": true,
|
"showWeapon": true,
|
||||||
"targetFacingAngle": 0.0,
|
"targetFacingAngle": 0.0,
|
||||||
"visibleRange": 6.0,
|
"visibleRange": 6.0,
|
||||||
"walkTarget": [
|
"walkTarget": [
|
||||||
-6, 0, 12.8
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scriptGlobals": null,
|
"player": {
|
||||||
"teleportZoneState": {},
|
"animationFrame": 0.0,
|
||||||
"triggerZoneState": {}
|
"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,
|
"persistenceVersion": 1
|
||||||
"playerHp": 200.0,
|
|
||||||
"playerMaxHp": 200.0,
|
|
||||||
"questJournal": {
|
|
||||||
"quests": []
|
|
||||||
},
|
|
||||||
"savedAt": "2026-07-24 23:40",
|
|
||||||
"taxiIsCalled": false,
|
|
||||||
"tutorialJournalScreenOpened": false,
|
|
||||||
"tutorialNeedOpenTaxiScreen": false,
|
|
||||||
"tutorialPhoneChatScreenOpened": false,
|
|
||||||
"tutorialStep": 9
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
-- uni_exterior level with static objects and navigation only.
|
-- uni_exterior level with static objects and navigation only.
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
||||||
|
function callback_init_location()
|
||||||
|
end
|
||||||
|
|
||||||
function callback_enter_location()
|
function callback_enter_location()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ function callback_exit_location()
|
|||||||
end
|
end
|
||||||
|
|
||||||
game_api.set_location_callbacks(
|
game_api.set_location_callbacks(
|
||||||
|
callback_init_location,
|
||||||
callback_enter_location,
|
callback_enter_location,
|
||||||
callback_exit_location
|
callback_exit_location
|
||||||
)
|
)
|
||||||
+26
-27
@@ -2,18 +2,8 @@
|
|||||||
-- NPC PATROL WAYPOINTS
|
-- NPC PATROL WAYPOINTS
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
||||||
bathroom_door_opened = false
|
function on_npc_interact(npc_id)
|
||||||
player_door_opened = false
|
if npc_id == "npc_alik" then
|
||||||
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
|
|
||||||
--[[
|
--[[
|
||||||
local player_alik_aware = game_api.getIntValue("alik_aware")
|
local player_alik_aware = game_api.getIntValue("alik_aware")
|
||||||
local player_container_aware = game_api.getIntValue("player_container_aware")
|
local player_container_aware = game_api.getIntValue("player_container_aware")
|
||||||
@@ -25,7 +15,7 @@ function on_npc_interact(npc_index)
|
|||||||
else
|
else
|
||||||
game_api.start_dialogue("dialog_alik001")
|
game_api.start_dialogue("dialog_alik001")
|
||||||
end]]
|
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")
|
game_api.start_dialogue("dialog_alik002")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -119,8 +109,8 @@ end
|
|||||||
|
|
||||||
function callback_deactivate_cover_alik_room()
|
function callback_deactivate_cover_alik_room()
|
||||||
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
|
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
|
||||||
game_api.set_npc_enabled(0, true)
|
game_api.set_npc_enabled("npc_alik", true)
|
||||||
game_api.npc_rotate_to(0, 90)
|
game_api.npc_rotate_to("npc_alik", 90)
|
||||||
end
|
end
|
||||||
|
|
||||||
function callback_deactivate_cover_bathroom()
|
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.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_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_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
|
if (bathroom_door_opened) then
|
||||||
game_api.switch_navigation(3) -- closed only alik (a)
|
game_api.switch_navigation(3) -- closed only alik (a)
|
||||||
else
|
else
|
||||||
@@ -330,11 +320,27 @@ function on_toilet_click()
|
|||||||
game_api.player_stop()
|
game_api.player_stop()
|
||||||
end
|
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()
|
function callback_enter_location()
|
||||||
print("Enter location dorm")
|
print("Enter location dorm")
|
||||||
local hp = game_api.getFloatValue("player_hp")
|
local hp = game_api.getFloatValue("player_hp")
|
||||||
game_api.set_player_hp(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
|
if (game_api.is_night()) then
|
||||||
closeAlikDoor()
|
closeAlikDoor()
|
||||||
end
|
end
|
||||||
@@ -347,25 +353,18 @@ function callback_exit_location()
|
|||||||
end
|
end
|
||||||
|
|
||||||
game_api.set_location_callbacks(
|
game_api.set_location_callbacks(
|
||||||
|
callback_init_location,
|
||||||
callback_enter_location,
|
callback_enter_location,
|
||||||
callback_exit_location
|
callback_exit_location
|
||||||
)
|
)
|
||||||
|
|
||||||
function callback_call_taxi()
|
function callback_call_taxi()
|
||||||
game_api.set_teleport_active(0, true)
|
game_api.set_teleport_active("tp_dorm_to_loc2", true)
|
||||||
game_api.set_trigger_zone_enabled(0, false)
|
game_api.set_trigger_zone_enabled("taxi_zone001", false)
|
||||||
end
|
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(
|
game_api.set_call_taxi_callback(
|
||||||
callback_call_taxi
|
callback_call_taxi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
game_api.set_npc_enabled(0, false)
|
|
||||||
game_api.switch_navigation(0)
|
|
||||||
print("Lua script loaded successfully--!")
|
print("Lua script loaded successfully--!")
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
print("Lua script loaded successfully!")
|
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()
|
function darklands_exit001_enter_callback()
|
||||||
if game_api.is_darklands() then
|
if game_api.is_darklands() then
|
||||||
print("Lua script start_darklands_transition")
|
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()
|
function callback_enter_location()
|
||||||
print("Enter location uni exterior")
|
print("Enter location uni exterior")
|
||||||
local hp = game_api.getFloatValue("player_hp")
|
local hp = game_api.getFloatValue("player_hp")
|
||||||
game_api.set_player_hp(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
|
if (game_api.is_night()) then
|
||||||
game_api.deactivate_interactive_object("car001")
|
game_api.deactivate_interactive_object("car001")
|
||||||
game_api.deactivate_interactive_object("car001wheels")
|
game_api.deactivate_interactive_object("car001wheels")
|
||||||
@@ -100,6 +92,7 @@ function callback_exit_location()
|
|||||||
end
|
end
|
||||||
|
|
||||||
game_api.set_location_callbacks(
|
game_api.set_location_callbacks(
|
||||||
|
callback_init_location,
|
||||||
callback_enter_location,
|
callback_enter_location,
|
||||||
callback_exit_location
|
callback_exit_location
|
||||||
)
|
)
|
||||||
@@ -108,10 +101,6 @@ game_api.set_trigger_zone_callbacks("darklands_exit001",
|
|||||||
darklands_exit001_enter_callback,
|
darklands_exit001_enter_callback,
|
||||||
nil
|
nil
|
||||||
)
|
)
|
||||||
game_api.set_trigger_zone_callbacks("darklands_exit002",
|
|
||||||
darklands_exit001_enter_callback,
|
|
||||||
nil
|
|
||||||
)
|
|
||||||
game_api.set_trigger_zone_callbacks("darklands_exit003",
|
game_api.set_trigger_zone_callbacks("darklands_exit003",
|
||||||
darklands_exit001_enter_callback,
|
darklands_exit001_enter_callback,
|
||||||
nil
|
nil
|
||||||
@@ -129,12 +118,12 @@ game_api.set_trigger_zone_callbacks("darklands_exit005",
|
|||||||
|
|
||||||
function taxi_zone001_enter_callback()
|
function taxi_zone001_enter_callback()
|
||||||
game_api.start_dialogue("dialog_taxi001")
|
game_api.start_dialogue("dialog_taxi001")
|
||||||
game_api.set_trigger_zone_enabled(0, false)
|
game_api.set_trigger_zone_enabled("taxi_zone001", false)
|
||||||
end
|
end
|
||||||
|
|
||||||
function callback_call_taxi()
|
function callback_call_taxi()
|
||||||
game_api.set_teleport_active(0, true)
|
game_api.set_teleport_active("tp_loc2_to_dorm", true)
|
||||||
game_api.set_trigger_zone_enabled(0, false)
|
game_api.set_trigger_zone_enabled("taxi_zone001", false)
|
||||||
end
|
end
|
||||||
|
|
||||||
game_api.set_call_taxi_callback(
|
game_api.set_call_taxi_callback(
|
||||||
|
|||||||
+129
-136
@@ -1,14 +1,6 @@
|
|||||||
CONST_MSG_EMPTY = "(пусто)"
|
CONST_MSG_EMPTY = "(пусто)"
|
||||||
CONST_MSG_WHERE = "Бекзат, ты где? Я жду..."
|
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
|
0 - day0
|
||||||
1 - darklands
|
1 - darklands
|
||||||
@@ -17,14 +9,6 @@ teacher_told_about_book = false
|
|||||||
4 - day
|
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()
|
function dialog_aiperi_give_key()
|
||||||
local player_hold_key = game_api.getIntValue("player_hold_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
|
if (player_hold_knife == 0) then
|
||||||
local lection_is_over = game_api.getIntValue("lection_is_over")
|
local lection_is_over = game_api.getIntValue("lection_is_over")
|
||||||
if lection_is_over == 1 then
|
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
|
if (player_hold_key == 1) then
|
||||||
game_api.start_dialogue("knife_dialog002")
|
game_api.start_dialogue("knife_dialog002")
|
||||||
else
|
else
|
||||||
@@ -79,7 +63,7 @@ function knife_dialog_zone001_enter_callback()
|
|||||||
if (aiperi_talked_after_knife == 0) then
|
if (aiperi_talked_after_knife == 0) then
|
||||||
game_api.start_dialogue("knife_dialog_second001")
|
game_api.start_dialogue("knife_dialog_second001")
|
||||||
end
|
end
|
||||||
game_api.set_trigger_zone_enabled(1, false)
|
game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -110,11 +94,11 @@ game_api.set_trigger_zone_callbacks("knife_dialog_zone001",
|
|||||||
function on_knife_pickup()
|
function on_knife_pickup()
|
||||||
game_api.pickup_item("knife")
|
game_api.pickup_item("knife")
|
||||||
game_api.deactivate_interactive_object("Knife001")
|
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.setIntValue("player_hold_knife", 1)
|
||||||
|
|
||||||
game_api.set_trigger_zone_enabled(2, true)
|
game_api.set_trigger_zone_enabled("teacher_dialog_zone001", true)
|
||||||
game_api.npc_walk_to(0, -4.57412, 0, 6.78495, on_teacher_arrived2)
|
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.quest_set_objective_completed("aiperi_knife", "aiperi_knife_take")
|
||||||
game_api.player_stop()
|
game_api.player_stop()
|
||||||
end
|
end
|
||||||
@@ -133,14 +117,14 @@ function on_book_pickup()
|
|||||||
player_hold_book = true
|
player_hold_book = true
|
||||||
game_api.quest_set_objective_completed("study_project", "study_project_book")
|
game_api.quest_set_objective_completed("study_project", "study_project_book")
|
||||||
game_api.start_dialogue("book_dialog003")
|
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
|
end
|
||||||
else
|
else
|
||||||
game_api.remove_item("book")
|
game_api.remove_item("book")
|
||||||
game_api.activate_interactive_object("Book001")
|
game_api.activate_interactive_object("Book001")
|
||||||
player_hold_book = false
|
player_hold_book = false
|
||||||
game_api.start_dialogue("book_dialog004")
|
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
|
end
|
||||||
else
|
else
|
||||||
print("on_book_pickup step2")
|
print("on_book_pickup step2")
|
||||||
@@ -218,14 +202,14 @@ function on_cake_clicked()
|
|||||||
game_api.player_stop()
|
game_api.player_stop()
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_npc_interact(npc_index)
|
function on_npc_interact(npc_id)
|
||||||
local player_ghost_aware = game_api.getIntValue("ghost_aware")
|
local player_ghost_aware = game_api.getIntValue("ghost_aware")
|
||||||
local player_hold_key = game_api.getIntValue("player_hold_key")
|
local player_hold_key = game_api.getIntValue("player_hold_key")
|
||||||
local day = game_api.getIntValue("day")
|
local day = game_api.getIntValue("day")
|
||||||
|
|
||||||
print("[Lua] NPC interaction! Index: " .. tostring(npc_index))
|
print("[Lua] NPC interaction! ID: " .. tostring(npc_id))
|
||||||
if npc_index == 1 then
|
if npc_id == "npc_02_woman" then
|
||||||
game_api.player_stop_and_rotate_to_npc(1)
|
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
|
||||||
if (game_api.is_dawn()) or (day > 0) then
|
if (game_api.is_dawn()) or (day > 0) then
|
||||||
game_api.start_dialogue("dialog_aiperi_morning001")
|
game_api.start_dialogue("dialog_aiperi_morning001")
|
||||||
else
|
else
|
||||||
@@ -236,8 +220,8 @@ function on_npc_interact(npc_index)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if npc_index == 0 then
|
if npc_id == "npc_teacher01" then
|
||||||
game_api.player_stop_and_rotate_to_npc(0)
|
game_api.player_stop_and_rotate_to_npc("npc_teacher01")
|
||||||
if (day == 0) then
|
if (day == 0) then
|
||||||
if (teacher_arrived_to_library) then
|
if (teacher_arrived_to_library) then
|
||||||
if (teacher_told_about_book) then
|
if (teacher_told_about_book) then
|
||||||
@@ -264,8 +248,8 @@ function on_npc_interact(npc_index)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if npc_index == 2 then
|
if npc_id == "npc_04_ghost" then
|
||||||
game_api.player_stop_and_rotate_to_npc(2)
|
game_api.player_stop_and_rotate_to_npc("npc_04_ghost")
|
||||||
local report_card_signed = game_api.getIntValue("report_card_signed")
|
local report_card_signed = game_api.getIntValue("report_card_signed")
|
||||||
if (report_card_signed == 1) then
|
if (report_card_signed == 1) then
|
||||||
game_api.start_dialogue("dialog_with_ghost003")
|
game_api.start_dialogue("dialog_with_ghost003")
|
||||||
@@ -273,18 +257,18 @@ function on_npc_interact(npc_index)
|
|||||||
game_api.start_dialogue("dialog_with_ghost001")
|
game_api.start_dialogue("dialog_with_ghost001")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if npc_index == 6 then
|
if npc_id == "npc_altynai" then
|
||||||
game_api.player_stop_and_rotate_to_npc(6)
|
game_api.player_stop_and_rotate_to_npc("npc_altynai")
|
||||||
game_api.start_dialogue("dialog_female_student002")
|
game_api.start_dialogue("dialog_female_student002")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_quest_over()
|
function on_quest_over()
|
||||||
ghost_gone = true
|
ghost_gone = true
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.quest_set_objective_completed("ghost_release", "ghost_release_show")
|
game_api.quest_set_objective_completed("ghost_release", "ghost_release_show")
|
||||||
|
|
||||||
game_api.start_cutscene("game_complete_cutscene001")
|
game_api.start_cutscene("game_complete_cutscene001")
|
||||||
@@ -297,7 +281,7 @@ end
|
|||||||
|
|
||||||
function teacher_zone001_enter_callback()
|
function teacher_zone001_enter_callback()
|
||||||
game_api.start_dialogue("teacher_dialog002")
|
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
|
teacher_told_about_book = true
|
||||||
game_api.player_stop()
|
game_api.player_stop()
|
||||||
end
|
end
|
||||||
@@ -474,9 +458,9 @@ function on_hall_door_click()
|
|||||||
game_api.switch_navigation(1)
|
game_api.switch_navigation(1)
|
||||||
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
|
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.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("npc_teacher01", true)
|
||||||
game_api.set_npc_enabled(6, true)
|
game_api.set_npc_enabled("npc_altynai", true)
|
||||||
game_api.npc_rotate_to(6, 180)
|
game_api.npc_rotate_to("npc_altynai", 180)
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
game_api.player_stop()
|
game_api.player_stop()
|
||||||
end
|
end
|
||||||
@@ -558,22 +542,22 @@ function on_n2_door_click()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function on_teacher_arrived()
|
function on_teacher_arrived()
|
||||||
game_api.npc_rotate_to(0, 180)
|
game_api.npc_rotate_to("npc_teacher01", 180)
|
||||||
teacher_arrived_to_library = true
|
teacher_arrived_to_library = true
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_student_arrived()
|
function on_student_arrived()
|
||||||
game_api.npc_rotate_to(6, 270)
|
game_api.npc_rotate_to("npc_altynai", 270)
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_teacher_arrived2()
|
function on_teacher_arrived2()
|
||||||
game_api.npc_rotate_to(0, 0)
|
game_api.npc_rotate_to("npc_teacher01", 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
function book_dialog_zone001_enter_callback()
|
function book_dialog_zone001_enter_callback()
|
||||||
if (game_api.is_darklands()) then
|
if (game_api.is_darklands()) then
|
||||||
game_api.start_dialogue("ghost_dialog002")
|
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()
|
game_api.advance_darklands_hud()
|
||||||
else
|
else
|
||||||
if (player_hold_book) and (game_api.is_night() == false) then
|
if (player_hold_book) and (game_api.is_night() == false) then
|
||||||
@@ -621,9 +605,9 @@ function on_sleep_cutscene()
|
|||||||
print("Cutscene 2 done!")
|
print("Cutscene 2 done!")
|
||||||
|
|
||||||
game_api.set_night()
|
game_api.set_night()
|
||||||
--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("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(6, false)
|
game_api.set_npc_enabled("npc_altynai", false)
|
||||||
game_api.switch_navigation(16)
|
game_api.switch_navigation(16)
|
||||||
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
||||||
game_api.set_object_rotation("Hall_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.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.fade_object("Room_Cover_North_3_001", 0, 0.5, callback_deactivate_cover_n3)
|
||||||
game_api.switch_navigation(2)
|
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")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_lection_cutscene_fadein_callback()
|
function on_lection_cutscene_fadein_callback()
|
||||||
print("Lection cutscene in progress!")
|
print("Lection cutscene in progress!")
|
||||||
-- -0.0234149 0 10.1139
|
-- -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
|
--4.91002 0 10.375
|
||||||
--game_api.player_set_position(-2.41002, 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!")
|
print("Cutscene done!")
|
||||||
game_api.setIntValue("lection_is_over", 1)
|
game_api.setIntValue("lection_is_over", 1)
|
||||||
|
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_rotate_to(1, 180)
|
game_api.npc_rotate_to("npc_02_woman", 180)
|
||||||
game_api.npc_walk_to(0, 0.758123, 0, 6.50063, on_teacher_arrived_intermediate)
|
game_api.npc_walk_to("npc_teacher01", 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.npc_walk_to("npc_altynai", 5.28998, 0, -5.525, on_student_arrived)
|
||||||
|
|
||||||
local aiperi_knife_aware = game_api.getIntValue("aiperi_knife_aware")
|
local aiperi_knife_aware = game_api.getIntValue("aiperi_knife_aware")
|
||||||
|
|
||||||
@@ -715,38 +699,36 @@ function on_bookshelf_teacher_clicked()
|
|||||||
on_report_card_pickup()
|
on_report_card_pickup()
|
||||||
end
|
end
|
||||||
|
|
||||||
first_time_darklands = true
|
|
||||||
|
|
||||||
function callback_enter_darklands()
|
function callback_enter_darklands()
|
||||||
if (first_time_darklands) then
|
if (first_time_darklands) then
|
||||||
game_api.start_dialogue("ghost_dialog001")
|
game_api.start_dialogue("ghost_dialog001")
|
||||||
first_time_darklands = false
|
first_time_darklands = false
|
||||||
end
|
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("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(1, false)
|
game_api.set_npc_enabled("npc_02_woman", false)
|
||||||
game_api.set_npc_enabled(6, false)
|
game_api.set_npc_enabled("npc_altynai", false)
|
||||||
|
|
||||||
if (ghost_gone) then
|
if (ghost_gone) then
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
else
|
else
|
||||||
game_api.set_npc_enabled(2, true)
|
game_api.set_npc_enabled("npc_04_ghost", true)
|
||||||
game_api.set_npc_enabled(3, true)
|
game_api.set_npc_enabled("ghost_01x", true)
|
||||||
game_api.set_npc_enabled(4, true)
|
game_api.set_npc_enabled("ghost_02x", true)
|
||||||
game_api.set_npc_enabled(5, true)
|
game_api.set_npc_enabled("ghost_03x", true)
|
||||||
|
|
||||||
game_api.npc_set_hp(3, 35)
|
game_api.npc_set_hp("ghost_01x", 35)
|
||||||
game_api.npc_set_hp(4, 35)
|
game_api.npc_set_hp("ghost_02x", 35)
|
||||||
game_api.npc_set_hp(5, 35)
|
game_api.npc_set_hp("ghost_03x", 35)
|
||||||
|
|
||||||
game_api.npc_set_position(3, 0, 0, -6.4)
|
game_api.npc_set_position("ghost_01x", 0, 0, -6.4)
|
||||||
game_api.npc_set_position(4, -5.0, 0, 12)
|
game_api.npc_set_position("ghost_02x", -5.0, 0, 12)
|
||||||
game_api.npc_set_position(5, 5.0, 0, 12)
|
game_api.npc_set_position("ghost_03x", 5.0, 0, 12)
|
||||||
end
|
end
|
||||||
|
|
||||||
game_api.switch_navigation(5)
|
game_api.switch_navigation(5)
|
||||||
@@ -755,13 +737,13 @@ end
|
|||||||
|
|
||||||
function callback_exit_darklands()
|
function callback_exit_darklands()
|
||||||
game_api.start_cutscene("darklands_exit001")
|
game_api.start_cutscene("darklands_exit001")
|
||||||
game_api.set_npc_enabled(0, false)
|
game_api.set_npc_enabled("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(1, false)
|
game_api.set_npc_enabled("npc_02_woman", false)
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.set_npc_enabled(6, false)
|
game_api.set_npc_enabled("npc_altynai", false)
|
||||||
|
|
||||||
morning_did_open_door_index = 0
|
morning_did_open_door_index = 0
|
||||||
setDay1MorningSetup()
|
setDay1MorningSetup()
|
||||||
@@ -873,13 +855,13 @@ function setDay0setup()
|
|||||||
|
|
||||||
game_api.deactivate_interactive_object("Note001")
|
game_api.deactivate_interactive_object("Note001")
|
||||||
|
|
||||||
game_api.set_npc_enabled(0, false)
|
game_api.set_npc_enabled("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(1, false)
|
game_api.set_npc_enabled("npc_02_woman", false)
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.set_npc_enabled(6, false)
|
game_api.set_npc_enabled("npc_altynai", false)
|
||||||
|
|
||||||
game_api.activate_interactive_object("car001")
|
game_api.activate_interactive_object("car001")
|
||||||
game_api.activate_interactive_object("car001wheels")
|
game_api.activate_interactive_object("car001wheels")
|
||||||
@@ -903,7 +885,6 @@ function setDay1setup()
|
|||||||
game_api.activate_interactive_object("Room_Cover_South_2_001")
|
game_api.activate_interactive_object("Room_Cover_South_2_001")
|
||||||
game_api.deactivate_interactive_object("Room_Cover_South_3_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_1_Leaf001", -90)
|
||||||
game_api.set_object_rotation("Room_N_2_Leaf001", -90)
|
game_api.set_object_rotation("Room_N_2_Leaf001", -90)
|
||||||
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
|
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("Room_S_2_Leaf001", 90)
|
||||||
game_api.set_object_rotation("Hall_Leaf001", 90)
|
game_api.set_object_rotation("Hall_Leaf001", 90)
|
||||||
|
|
||||||
game_api.set_npc_enabled(0, true)
|
game_api.set_npc_enabled("npc_teacher01", true)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.set_npc_enabled(6, true)
|
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")
|
game_api.deactivate_interactive_object("Cake001")
|
||||||
|
|
||||||
@@ -939,7 +920,6 @@ function setDay1MorningSetup()
|
|||||||
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
|
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
|
||||||
game_api.activate_interactive_object("Room_Cover_Main_Hall_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_1_Leaf001", 0)
|
||||||
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
||||||
game_api.set_object_rotation("Room_S_0_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
|
-- TODO: Make check for teacher_door_opened false or true
|
||||||
|
|
||||||
game_api.set_npc_enabled(0, false)
|
game_api.set_npc_enabled("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(1, false)
|
game_api.set_npc_enabled("npc_02_woman", false)
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.set_npc_enabled(6, 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
|
if morning_did_open_door_index == 1 then
|
||||||
game_api.deactivate_interactive_object("Room_Cover_South_1_001")
|
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.deactivate_interactive_object("Room_Cover_Corridor_001")
|
||||||
game_api.activate_interactive_object("Room_Cover_Main_Hall_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_1_Leaf001", 0)
|
||||||
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
|
||||||
game_api.set_object_rotation("Room_S_0_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_Corridor_001", 1)
|
||||||
game_api.set_object_alpha("Room_Cover_Main_Hall_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("npc_teacher01", false)
|
||||||
game_api.set_npc_enabled(1, false)
|
game_api.set_npc_enabled("npc_02_woman", false)
|
||||||
game_api.set_npc_enabled(2, false)
|
game_api.set_npc_enabled("npc_04_ghost", false)
|
||||||
game_api.set_npc_enabled(3, false)
|
game_api.set_npc_enabled("ghost_01x", false)
|
||||||
game_api.set_npc_enabled(4, false)
|
game_api.set_npc_enabled("ghost_02x", false)
|
||||||
game_api.set_npc_enabled(5, false)
|
game_api.set_npc_enabled("ghost_03x", false)
|
||||||
game_api.set_npc_enabled(6, false)
|
game_api.set_npc_enabled("npc_altynai", false)
|
||||||
|
|
||||||
game_api.deactivate_interactive_object("car001")
|
game_api.deactivate_interactive_object("car001")
|
||||||
game_api.deactivate_interactive_object("car001wheels")
|
game_api.deactivate_interactive_object("car001wheels")
|
||||||
@@ -1222,8 +1201,8 @@ game_api.set_chat_callbacks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function callback_aiperi_dialog_morning()
|
function callback_aiperi_dialog_morning()
|
||||||
game_api.player_stop_and_rotate_to_npc(1)
|
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
|
||||||
game_api.npc_stop_and_rotate_to_player(1)
|
game_api.npc_stop_and_rotate_to_player("npc_02_woman")
|
||||||
game_api.start_dialogue("dialog_aiperi_morning001")
|
game_api.start_dialogue("dialog_aiperi_morning001")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1246,9 +1225,9 @@ function on_aiperi_opens_door()
|
|||||||
end
|
end
|
||||||
|
|
||||||
--Aiperi incoming
|
--Aiperi incoming
|
||||||
game_api.npc_set_position(1, 0, 0, 6.5)
|
game_api.npc_set_position("npc_02_woman", 0, 0, 6.5)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_walk_to(1, 0.0, 0, 9.1, callback_aiperi_dialog_morning)
|
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.player_walk_to(0.0, 0.0, 10.0, nil)
|
||||||
|
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
@@ -1268,9 +1247,9 @@ function on_aiperi_opens_door()
|
|||||||
end
|
end
|
||||||
|
|
||||||
--Aiperi incoming
|
--Aiperi incoming
|
||||||
game_api.npc_set_position(1, 0.0, 0, 6.43818)
|
game_api.npc_set_position("npc_02_woman", 0.0, 0, 6.43818)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_walk_to(1, 2.34349, 0, 6.31229, callback_aiperi_dialog_morning)
|
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.player_walk_to(3.52174, 0, 6.28993, nil)
|
||||||
|
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
@@ -1290,9 +1269,9 @@ function on_aiperi_opens_door()
|
|||||||
end
|
end
|
||||||
|
|
||||||
--Aiperi incoming
|
--Aiperi incoming
|
||||||
game_api.npc_set_position(1, 0, 0, -1.7911)
|
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_walk_to(1, 2.11898, 0, -1.7074, callback_aiperi_dialog_morning)
|
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.player_walk_to(3.36055, 0, -2.0345, nil)
|
||||||
|
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
@@ -1312,9 +1291,9 @@ function on_aiperi_opens_door()
|
|||||||
game_api.switch_navigation(9)
|
game_api.switch_navigation(9)
|
||||||
end
|
end
|
||||||
--Aiperi incoming
|
--Aiperi incoming
|
||||||
game_api.npc_set_position(1, 0, 0, -1.7911)
|
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_walk_to(1, -2.454, 0, -1.61871, callback_aiperi_dialog_morning)
|
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.player_walk_to(-3.63363, 0, -1.50727, nil)
|
||||||
|
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
@@ -1334,9 +1313,9 @@ function on_aiperi_opens_door()
|
|||||||
end
|
end
|
||||||
|
|
||||||
--Aiperi incoming
|
--Aiperi incoming
|
||||||
game_api.npc_set_position(1, 0, 0, -9.77948)
|
game_api.npc_set_position("npc_02_woman", 0, 0, -9.77948)
|
||||||
game_api.set_npc_enabled(1, true)
|
game_api.set_npc_enabled("npc_02_woman", true)
|
||||||
game_api.npc_walk_to(1, -2.53977, 0, -9.77852, callback_aiperi_dialog_morning)
|
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.player_walk_to(-3.64494, 0, -9.66711, nil)
|
||||||
|
|
||||||
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
|
||||||
@@ -1345,7 +1324,24 @@ function on_aiperi_opens_door()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function on_aiperi_morning_dialog_finished()
|
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
|
end
|
||||||
|
|
||||||
function callback_enter_location()
|
function callback_enter_location()
|
||||||
@@ -1437,6 +1433,7 @@ function callback_exit_location()
|
|||||||
end
|
end
|
||||||
|
|
||||||
game_api.set_location_callbacks(
|
game_api.set_location_callbacks(
|
||||||
|
callback_init_location,
|
||||||
callback_enter_location,
|
callback_enter_location,
|
||||||
callback_exit_location
|
callback_exit_location
|
||||||
)
|
)
|
||||||
@@ -1467,9 +1464,5 @@ end
|
|||||||
|
|
||||||
game_api.set_cutscene_callback("game_complete_cutscene001", callback_game_complete_cutscene)
|
game_api.set_cutscene_callback("game_complete_cutscene001", callback_game_complete_cutscene)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setDay0setup()
|
|
||||||
game_api.switch_navigation(0)
|
|
||||||
--debugAllOpen()
|
--debugAllOpen()
|
||||||
print("Lua script loaded successfully!---!")
|
print("Lua script loaded successfully!---!")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"objects": [
|
"objects": [
|
||||||
{
|
{
|
||||||
|
"id": "intBin_Cans",
|
||||||
"name": "intBin_Cans",
|
"name": "intBin_Cans",
|
||||||
"texturePath": "resources/w/shelter/models/intBin_Cans_atlas.png",
|
"texturePath": "resources/w/shelter/models/intBin_Cans_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intBin_Cans.txt.bin",
|
"meshPath": "resources/w/shelter/models/intBin_Cans.txt.bin",
|
||||||
@@ -8,12 +9,13 @@
|
|||||||
"rotationY": -180.0,
|
"rotationY": -180.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.6,
|
"positionX": 0.6,
|
||||||
"positionY": 0.0,
|
"positionY": 0.45736101269721985,
|
||||||
"positionZ": 3.85,
|
"positionZ": 3.85,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intBin_Plastic",
|
||||||
"name": "intBin_Plastic",
|
"name": "intBin_Plastic",
|
||||||
"texturePath": "resources/w/shelter/models/intBin_Plastic_atlas.png",
|
"texturePath": "resources/w/shelter/models/intBin_Plastic_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intBin_Plastic.txt.bin",
|
"meshPath": "resources/w/shelter/models/intBin_Plastic.txt.bin",
|
||||||
@@ -21,12 +23,13 @@
|
|||||||
"rotationY": -180.0,
|
"rotationY": -180.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.4,
|
"positionX": 1.4,
|
||||||
"positionY": 0.0,
|
"positionY": 0.45736101269721985,
|
||||||
"positionZ": 3.85,
|
"positionZ": 3.85,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intBin_Waste_A",
|
||||||
"name": "intBin_Waste_A",
|
"name": "intBin_Waste_A",
|
||||||
"texturePath": "resources/w/shelter/models/intBin_Waste_A_atlas.png",
|
"texturePath": "resources/w/shelter/models/intBin_Waste_A_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intBin_Waste_A.txt.bin",
|
"meshPath": "resources/w/shelter/models/intBin_Waste_A.txt.bin",
|
||||||
@@ -34,12 +37,13 @@
|
|||||||
"rotationY": -180.0,
|
"rotationY": -180.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 2.2,
|
"positionX": 2.2,
|
||||||
"positionY": 0.0,
|
"positionY": 0.45736101269721985,
|
||||||
"positionZ": 3.85,
|
"positionZ": 3.85,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intBin_Waste_B",
|
||||||
"name": "intBin_Waste_B",
|
"name": "intBin_Waste_B",
|
||||||
"texturePath": "resources/w/shelter/models/intBin_Waste_B_atlas.png",
|
"texturePath": "resources/w/shelter/models/intBin_Waste_B_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intBin_Waste_B.txt.bin",
|
"meshPath": "resources/w/shelter/models/intBin_Waste_B.txt.bin",
|
||||||
@@ -47,12 +51,13 @@
|
|||||||
"rotationY": -180.0,
|
"rotationY": -180.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 3.0,
|
"positionX": 3.0,
|
||||||
"positionY": 0.0,
|
"positionY": 0.45736101269721985,
|
||||||
"positionZ": 3.85,
|
"positionZ": 3.85,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intRoof",
|
||||||
"name": "intRoof",
|
"name": "intRoof",
|
||||||
"texturePath": "resources/w/shelter/models/intRoof_atlas.png",
|
"texturePath": "resources/w/shelter/models/intRoof_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intRoof.txt",
|
"meshPath": "resources/w/shelter/models/intRoof.txt",
|
||||||
@@ -60,12 +65,13 @@
|
|||||||
"rotationY": 0.0,
|
"rotationY": 0.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": 0.0,
|
"positionY": 3.2,
|
||||||
"positionZ": 0.0,
|
"positionZ": 0.0,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intVending.00",
|
||||||
"name": "intVending.00",
|
"name": "intVending.00",
|
||||||
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intVending.txt",
|
"meshPath": "resources/w/shelter/models/intVending.txt",
|
||||||
@@ -73,12 +79,13 @@
|
|||||||
"rotationY": -90.0,
|
"rotationY": -90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": -1.0,
|
"positionX": -1.0,
|
||||||
"positionY": 0.0,
|
"positionY": 0.915,
|
||||||
"positionZ": -3.9,
|
"positionZ": -3.9,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intVending.01",
|
||||||
"name": "intVending.01",
|
"name": "intVending.01",
|
||||||
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intVending.txt",
|
"meshPath": "resources/w/shelter/models/intVending.txt",
|
||||||
@@ -86,12 +93,13 @@
|
|||||||
"rotationY": -90.0,
|
"rotationY": -90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 0.0,
|
"positionX": 0.0,
|
||||||
"positionY": 0.0,
|
"positionY": 0.915,
|
||||||
"positionZ": -3.9,
|
"positionZ": -3.9,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.0
|
"interactionRadius": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "intVending.02",
|
||||||
"name": "intVending.02",
|
"name": "intVending.02",
|
||||||
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
"texturePath": "resources/w/shelter/models/vending_atlas.png",
|
||||||
"meshPath": "resources/w/shelter/models/intVending.txt",
|
"meshPath": "resources/w/shelter/models/intVending.txt",
|
||||||
@@ -99,7 +107,7 @@
|
|||||||
"rotationY": -90.0,
|
"rotationY": -90.0,
|
||||||
"rotationZ": 0.0,
|
"rotationZ": 0.0,
|
||||||
"positionX": 1.0,
|
"positionX": 1.0,
|
||||||
"positionY": 0.0,
|
"positionY": 0.915,
|
||||||
"positionZ": -3.9,
|
"positionZ": -3.9,
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"interactionRadius": 0.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;
|
||||||
|
}
|
||||||
@@ -722,9 +722,10 @@ namespace FRG
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void BoneSystemNew::Interpolate(int frame)
|
void BoneSystemNew::Interpolate(float frame)
|
||||||
{
|
{
|
||||||
int startingKeyFrame = -1;
|
int startingKeyFrame = -1;
|
||||||
|
int nextKeyFrame = -1;
|
||||||
for (int i = 0; i < static_cast<int>(animations[0].keyFrames.size()) - 1; i++)
|
for (int i = 0; i < static_cast<int>(animations[0].keyFrames.size()) - 1; i++)
|
||||||
{
|
{
|
||||||
int oldFrame = animations[0].keyFrames[i].frame;
|
int oldFrame = animations[0].keyFrames[i].frame;
|
||||||
@@ -732,22 +733,29 @@ namespace FRG
|
|||||||
if (frame >= oldFrame && frame < nextFrame)
|
if (frame >= oldFrame && frame < nextFrame)
|
||||||
{
|
{
|
||||||
startingKeyFrame = i;
|
startingKeyFrame = i;
|
||||||
|
nextKeyFrame = i + 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startingKeyFrame == -1)
|
if (startingKeyFrame == -1)
|
||||||
{
|
{
|
||||||
logger() << "Exception here: frame number is out of range of keyframes. Frame: " << frame << std::endl;
|
if (animations[0].keyFrames.size() < 2)
|
||||||
throw std::runtime_error("Exception here");
|
return;
|
||||||
|
startingKeyFrame = static_cast<int>(animations[0].keyFrames.size()) - 1;
|
||||||
|
nextKeyFrame = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame;
|
float modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame;
|
||||||
int diffFrames = animations[0].keyFrames[startingKeyFrame + 1].frame - animations[0].keyFrames[startingKeyFrame].frame;
|
int diffFrames = nextKeyFrame == 0
|
||||||
float t = (modifiedFrameNumber + 0.f) / diffFrames;
|
? animations[0].keyFrames.back().frame -
|
||||||
|
animations[0].keyFrames.front().frame + 1
|
||||||
|
: animations[0].keyFrames[nextKeyFrame].frame -
|
||||||
|
animations[0].keyFrames[startingKeyFrame].frame;
|
||||||
|
float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f;
|
||||||
|
|
||||||
std::vector<Bone>& oneFrameBones = animations[0].keyFrames[startingKeyFrame].bones;
|
std::vector<Bone>& oneFrameBones = animations[0].keyFrames[startingKeyFrame].bones;
|
||||||
std::vector<Bone>& nextFrameBones = animations[0].keyFrames[startingKeyFrame + 1].bones;
|
std::vector<Bone>& nextFrameBones = animations[0].keyFrames[nextKeyFrame].bones;
|
||||||
|
|
||||||
std::vector<Matrix4f> skinningMatrixForEachBone(currentBones.size());
|
std::vector<Matrix4f> skinningMatrixForEachBone(currentBones.size());
|
||||||
|
|
||||||
@@ -906,9 +914,10 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, int frame)
|
void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, float frame)
|
||||||
{
|
{
|
||||||
int startingKeyFrame = -1;
|
int startingKeyFrame = -1;
|
||||||
|
int nextKeyFrame = -1;
|
||||||
for (size_t i = 0; i < keyFrames.size() - 1; i++)
|
for (size_t i = 0; i < keyFrames.size() - 1; i++)
|
||||||
{
|
{
|
||||||
int oldFrame = keyFrames[i].frame;
|
int oldFrame = keyFrames[i].frame;
|
||||||
@@ -916,6 +925,7 @@ namespace FRG
|
|||||||
if (frame >= oldFrame && frame < nextFrame)
|
if (frame >= oldFrame && frame < nextFrame)
|
||||||
{
|
{
|
||||||
startingKeyFrame = static_cast<int>(i);
|
startingKeyFrame = static_cast<int>(i);
|
||||||
|
nextKeyFrame = static_cast<int>(i + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -924,16 +934,23 @@ namespace FRG
|
|||||||
|
|
||||||
if (startingKeyFrame == -1)
|
if (startingKeyFrame == -1)
|
||||||
{
|
{
|
||||||
for (auto& m : skinningMatrices) m = Matrix4f::Identity();
|
if (keyFrames.size() < 2)
|
||||||
return;
|
{
|
||||||
|
for (auto& m : skinningMatrices) m = Matrix4f::Identity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startingKeyFrame = static_cast<int>(keyFrames.size()) - 1;
|
||||||
|
nextKeyFrame = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame;
|
float modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame;
|
||||||
int diffFrames = keyFrames[startingKeyFrame + 1].frame - keyFrames[startingKeyFrame].frame;
|
int diffFrames = nextKeyFrame == 0
|
||||||
float t = (modifiedFrameNumber + 0.f) / diffFrames;
|
? keyFrames.back().frame - keyFrames.front().frame + 1
|
||||||
|
: keyFrames[nextKeyFrame].frame - keyFrames[startingKeyFrame].frame;
|
||||||
|
float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f;
|
||||||
|
|
||||||
const std::vector<Bone>& oneFrameBones = keyFrames[startingKeyFrame].bones;
|
const std::vector<Bone>& oneFrameBones = keyFrames[startingKeyFrame].bones;
|
||||||
const std::vector<Bone>& nextFrameBones = keyFrames[startingKeyFrame + 1].bones;
|
const std::vector<Bone>& nextFrameBones = keyFrames[nextKeyFrame].bones;
|
||||||
|
|
||||||
for (size_t i = 0; i < startBones.size(); i++)
|
for (size_t i = 0; i < startBones.size(); i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ namespace FRG
|
|||||||
void LoadFromFile(const std::string& fileName, const std::string& ZIPFileName = "");
|
void LoadFromFile(const std::string& fileName, const std::string& ZIPFileName = "");
|
||||||
void LoadFromBinaryFile(const std::string& fileName, const std::string& ZIPFileName = "");
|
void LoadFromBinaryFile(const std::string& fileName, const std::string& ZIPFileName = "");
|
||||||
|
|
||||||
void Interpolate(int frame);
|
void Interpolate(float frame);
|
||||||
|
|
||||||
int findBoneIndex(const std::string& name) const;
|
int findBoneIndex(const std::string& name) const;
|
||||||
};
|
};
|
||||||
@@ -94,14 +94,14 @@ namespace FRG
|
|||||||
|
|
||||||
void prepareGpuSkinningVBOs(BoneSystemNew& model);
|
void prepareGpuSkinningVBOs(BoneSystemNew& model);
|
||||||
void RenderVBO(Renderer& renderer, const std::vector<std::string>& meshNamesOrdered);
|
void RenderVBO(Renderer& renderer, const std::vector<std::string>& meshNamesOrdered);
|
||||||
void ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, int frame);
|
void ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, float frame);
|
||||||
};
|
};
|
||||||
|
|
||||||
struct BoneAnimationDataNew
|
struct BoneAnimationDataNew
|
||||||
{
|
{
|
||||||
BoneSystemNew model;
|
BoneSystemNew model;
|
||||||
float currentFrame = 0.f;
|
float currentFrame = 0.f;
|
||||||
int lastFrame = -1;
|
float lastFrame = -1.0f;
|
||||||
int totalFrames = 1;
|
int totalFrames = 1;
|
||||||
|
|
||||||
GpuSkinningShaderDataNew gpuSkinningShaderData;
|
GpuSkinningShaderDataNew gpuSkinningShaderData;
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ namespace FRG {
|
|||||||
c.startStatePath = entry.value("startState", "");
|
c.startStatePath = entry.value("startState", "");
|
||||||
c.questsPath = entry.value("quests", "");
|
c.questsPath = entry.value("quests", "");
|
||||||
c.tutorial = entry.value("tutorial", false);
|
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));
|
c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen));
|
||||||
for (const std::string& path : c.locationConfigPaths) {
|
for (const std::string& path : c.locationConfigPaths) {
|
||||||
LocationConfig loc;
|
LocationConfig loc;
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ namespace FRG {
|
|||||||
std::string startStatePath;
|
std::string startStatePath;
|
||||||
std::string questsPath;
|
std::string questsPath;
|
||||||
bool tutorial = false;
|
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).
|
// UI layout shown while the campaign loads (preloaded with the rest of the UI).
|
||||||
std::string loadingScreenPath = kDefaultLoadingScreen;
|
std::string loadingScreenPath = kDefaultLoadingScreen;
|
||||||
|
|
||||||
|
|||||||
+271
-440
File diff suppressed because it is too large.
Load diff
+65
-53
@@ -11,30 +11,28 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
#include <chrono>
|
||||||
#include "dialogue/TranslationDatabase.h"
|
#include "dialogue/TranslationDatabase.h"
|
||||||
#include "AudioPlayerAsync.h"
|
#include "AudioPlayerAsync.h"
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
class TextRenderer;
|
class TextRenderer;
|
||||||
|
class IGameServer;
|
||||||
|
|
||||||
class Character {
|
class Character {
|
||||||
public:
|
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;
|
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 ----
|
// ---- Asset loading ----
|
||||||
void loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
|
void loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
|
||||||
void loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
|
void loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
|
||||||
@@ -46,31 +44,19 @@ public:
|
|||||||
|
|
||||||
// Creates a fully-loaded Character from a CharacterState (including its creationInfo).
|
// 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.
|
// 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 ----
|
// ---- Per-frame update ----
|
||||||
// resolver maps character indices to live pointers so attackTargetIndex / faceTargetIndex
|
// Applies authoritative animation state to runtime resources and consumes
|
||||||
// can be dereferenced inside update(). Pass nullptr only if no targets are set.
|
// presentation-only events emitted by CharacterState::update.
|
||||||
void update(int64_t deltaMs, const CharacterResolver& resolver = nullptr);
|
void update(int64_t deltaMs, const CharacterUpdateEvents* events = 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();
|
|
||||||
|
|
||||||
// ---- Damage ----
|
// ---- 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 ----
|
// ---- Spark setup ----
|
||||||
void setupHitSparks(std::shared_ptr<Texture> sparkTexture);
|
void setupHitSparks(std::shared_ptr<Texture> sparkTexture);
|
||||||
@@ -80,6 +66,10 @@ public:
|
|||||||
void drawName(TextRenderer& textRenderer,
|
void drawName(TextRenderer& textRenderer,
|
||||||
const Eigen::Matrix4f& cameraViewMatrix,
|
const Eigen::Matrix4f& cameraViewMatrix,
|
||||||
const Eigen::Matrix4f& projectionMatrix);
|
const Eigen::Matrix4f& projectionMatrix);
|
||||||
|
void drawPlayerName(TextRenderer& textRenderer,
|
||||||
|
const Eigen::Matrix4f& cameraViewMatrix,
|
||||||
|
const Eigen::Matrix4f& projectionMatrix,
|
||||||
|
const std::string& displayName);
|
||||||
void drawHealthBar(Renderer& renderer,
|
void drawHealthBar(Renderer& renderer,
|
||||||
const Eigen::Matrix4f& cameraViewMatrix,
|
const Eigen::Matrix4f& cameraViewMatrix,
|
||||||
const Eigen::Matrix4f& projectionMatrix);
|
const Eigen::Matrix4f& projectionMatrix);
|
||||||
@@ -101,14 +91,12 @@ public:
|
|||||||
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
|
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
|
||||||
const float* ambientColor, const float* fogColor);
|
const float* ambientColor, const float* fogColor);
|
||||||
|
|
||||||
// ---- Query helpers (delegate to state) ----
|
// ---- Query helpers (read immutable snapshots) ----
|
||||||
bool isMoving() const { return state.isMoving(); }
|
bool isMoving() const;
|
||||||
const Eigen::Vector3f& getRequestedWalkTarget() const { return state.getRequestedWalkTarget(); }
|
Eigen::Vector3f getRequestedWalkTarget() const;
|
||||||
Eigen::Vector3f getCurrentNavigationTarget() const { return state.getCurrentNavigationTarget(); }
|
Eigen::Vector3f getCurrentNavigationTarget() const;
|
||||||
float getHp() const { return state.getHp(); }
|
Eigen::Vector3f renderPosition() const;
|
||||||
void setHp(float newHp) { state.setHp(newHp); }
|
float getHp() const;
|
||||||
|
|
||||||
float getSpeedMultiplier(float transitionProgress) const;
|
|
||||||
|
|
||||||
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
|
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
|
||||||
VertexRenderStruct weaponMesh;
|
VertexRenderStruct weaponMesh;
|
||||||
@@ -123,38 +111,62 @@ public:
|
|||||||
static Dialogue::TranslationDatabase nameTranslations;
|
static Dialogue::TranslationDatabase nameTranslations;
|
||||||
|
|
||||||
private:
|
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;
|
std::map<AnimationState, BoneAnimationDataNew> animations;
|
||||||
VertexRenderStruct modelMutable;
|
VertexRenderStruct modelMutable;
|
||||||
VertexRenderStruct healthBarMesh;
|
VertexRenderStruct healthBarMesh;
|
||||||
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
|
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
|
||||||
|
|
||||||
PathPlanner pathPlanner;
|
struct PresentationSample {
|
||||||
|
Eigen::Vector3f position = Eigen::Vector3f::Zero();
|
||||||
|
float facingAngle = 0.0f;
|
||||||
|
float animationFrame = 0.0f;
|
||||||
|
AnimationState currentState = AnimationState::STAND;
|
||||||
|
bool enabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
static constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
|
PresentationSample previousPresentationSample_;
|
||||||
static constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
|
PresentationSample currentPresentationSample_;
|
||||||
|
std::chrono::steady_clock::time_point lastPresentationUpdate_;
|
||||||
|
bool hasPresentationSample_ = false;
|
||||||
|
|
||||||
AnimationState resolveActiveState() const;
|
AnimationState resolveActiveState(const CharacterState& state) const;
|
||||||
bool prepareGpuSkinning();
|
CharacterState interpolatedState(const CharacterState& state) const;
|
||||||
void drawAttachedWeapon(Renderer& renderer);
|
void updateAnimationForRender(const CharacterState& state);
|
||||||
|
bool prepareGpuSkinning(const CharacterState& state);
|
||||||
|
void drawAttachedWeapon(Renderer& renderer, const CharacterState& state);
|
||||||
|
|
||||||
void drawGpuSkinning(Renderer& renderer);
|
void drawGpuSkinning(Renderer& renderer, const CharacterState& state);
|
||||||
void drawShadowDepthGpuSkinning(Renderer& renderer);
|
void drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterState& state);
|
||||||
void drawShadowDepthCpu(Renderer& renderer);
|
void drawShadowDepthCpu(Renderer& renderer, const CharacterState& state);
|
||||||
void drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
|
void drawGpuSkinningWithShadow(Renderer& renderer, const CharacterState& state, 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 drawCpuWithShadow(Renderer& renderer, const CharacterState& state, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera);
|
||||||
void drawNightGpuSkinning(Renderer& renderer,
|
void drawNightGpuSkinning(Renderer& renderer,
|
||||||
|
const CharacterState& state,
|
||||||
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
||||||
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
||||||
const float* ambientColor, const float* fogColor);
|
const float* ambientColor, const float* fogColor);
|
||||||
void drawNightCpu(Renderer& renderer,
|
void drawNightCpu(Renderer& renderer,
|
||||||
|
const CharacterState& state,
|
||||||
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
||||||
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
||||||
const float* ambientColor, const float* fogColor);
|
const float* ambientColor, const float* fogColor);
|
||||||
void drawNightGpuSkinningWithShadow(Renderer& renderer,
|
void drawNightGpuSkinningWithShadow(Renderer& renderer,
|
||||||
|
const CharacterState& state,
|
||||||
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
||||||
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
||||||
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
|
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
|
||||||
void drawNightCpuWithShadow(Renderer& renderer,
|
void drawNightCpuWithShadow(Renderer& renderer,
|
||||||
|
const CharacterState& state,
|
||||||
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
const float* plPosEye, const float* plDirEye, const float* plColors, int plCount,
|
||||||
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
const float* plWorldXZ, const float* plLimitXZ, const Eigen::Matrix4f& cameraViewInverse,
|
||||||
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
|
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const float* ambientColor, const float* fogColor);
|
||||||
|
|||||||
+319
-6
@@ -1,7 +1,28 @@
|
|||||||
#include "CharacterState.h"
|
#include "CharacterState.h"
|
||||||
|
#include "LocationState.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace FRG {
|
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 {
|
bool CharacterState::isMoving() const {
|
||||||
Eigen::Vector3f toTarget = walkTarget - position;
|
Eigen::Vector3f toTarget = walkTarget - position;
|
||||||
toTarget.y() = 0.f;
|
toTarget.y() = 0.f;
|
||||||
@@ -20,14 +41,304 @@ void CharacterState::stopInPlace() {
|
|||||||
requestedWalkTarget = walkTarget;
|
requestedWalkTarget = walkTarget;
|
||||||
pathWaypoints.clear();
|
pathWaypoints.clear();
|
||||||
currentWaypointIndex = 0;
|
currentWaypointIndex = 0;
|
||||||
onArrivedCallback = nullptr;
|
|
||||||
onArrivedCallbackName.clear();
|
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) {
|
void CharacterState::setHp(float newHp) {
|
||||||
hp = newHp;
|
hp = newHp;
|
||||||
if (hp < 0.f) hp = 0.f;
|
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
|
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["currentState"] = static_cast<int>(currentState);
|
||||||
out["attack_cooldown"] = attack_cooldown;
|
out["attack_cooldown"] = attack_cooldown;
|
||||||
out["showWeapon"] = showWeapon;
|
out["showWeapon"] = showWeapon;
|
||||||
out["attackTargetIndex"] = attackTargetIndex;
|
out["attackTargetId"] = attackTargetId;
|
||||||
out["faceTargetIndex"] = faceTargetIndex;
|
out["faceTargetId"] = faceTargetId;
|
||||||
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() };
|
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() };
|
||||||
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() };
|
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() };
|
||||||
out["requestedWalkTarget"] = { requestedWalkTarget.x(), requestedWalkTarget.y(), requestedWalkTarget.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["pathWaypoints"] = std::move(wpArr);
|
||||||
out["currentWaypointIndex"] = currentWaypointIndex;
|
out["currentWaypointIndex"] = currentWaypointIndex;
|
||||||
out["homeDriftCheckTimer"] = homeDriftCheckTimer;
|
out["homeDriftCheckTimer"] = homeDriftCheckTimer;
|
||||||
|
out["animationFrame"] = animationFrame;
|
||||||
out["visibleRange"] = visibleRange;
|
out["visibleRange"] = visibleRange;
|
||||||
if (!onArrivedCallbackName.empty())
|
if (!onArrivedCallbackName.empty())
|
||||||
out["onArrivedCallbackName"] = onArrivedCallbackName;
|
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)));
|
currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState)));
|
||||||
attack_cooldown = in.value("attack_cooldown", attack_cooldown);
|
attack_cooldown = in.value("attack_cooldown", attack_cooldown);
|
||||||
showWeapon = in.value("showWeapon", showWeapon);
|
showWeapon = in.value("showWeapon", showWeapon);
|
||||||
attackTargetIndex = in.value("attackTargetIndex", attackTargetIndex);
|
attackTargetId = in.value("attackTargetId", attackTargetId);
|
||||||
faceTargetIndex = in.value("faceTargetIndex", faceTargetIndex);
|
faceTargetId = in.value("faceTargetId", faceTargetId);
|
||||||
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition);
|
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition);
|
||||||
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget);
|
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget);
|
||||||
if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3(in["requestedWalkTarget"], requestedWalkTarget);
|
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);
|
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
|
||||||
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
|
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
|
||||||
|
animationFrame = in.value("animationFrame", animationFrame);
|
||||||
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
|
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-20
@@ -2,7 +2,9 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <functional>
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
#include <Eigen/Core>
|
#include <Eigen/Core>
|
||||||
#include <Eigen/Geometry>
|
#include <Eigen/Geometry>
|
||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
@@ -12,6 +14,10 @@ namespace FRG {
|
|||||||
|
|
||||||
constexpr float VISIBLE_RANGE = 6.f;
|
constexpr float VISIBLE_RANGE = 6.f;
|
||||||
|
|
||||||
|
struct CharacterUpdateContext;
|
||||||
|
struct CharacterUpdateEvents;
|
||||||
|
struct LocationNetworkState;
|
||||||
|
|
||||||
enum class AnimationState {
|
enum class AnimationState {
|
||||||
STAND = 0,
|
STAND = 0,
|
||||||
WALK = 1,
|
WALK = 1,
|
||||||
@@ -24,6 +30,8 @@ enum class AnimationState {
|
|||||||
DEATH_IDLE = 8,
|
DEATH_IDLE = 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Paths and fixed parameters needed to recreate a Character's visual assets
|
// Paths and fixed parameters needed to recreate a Character's visual assets
|
||||||
// from scratch (e.g. after loading a saved game).
|
// from scratch (e.g. after loading a saved game).
|
||||||
struct CharacterCreationInfo {
|
struct CharacterCreationInfo {
|
||||||
@@ -45,19 +53,14 @@ struct CharacterCreationInfo {
|
|||||||
float weaponMeshScale = 1.0f;
|
float weaponMeshScale = 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
// All serializable game state for a character.
|
// All serializable authoritative state for a character. GameState or
|
||||||
// This is a member of Character; rendering-only data lives in Character itself.
|
// LocationGameState owns it; rendering-only data lives in Character.
|
||||||
class CharacterState : public ISaveable {
|
class CharacterState : public ISaveable {
|
||||||
public:
|
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.
|
// Distance below which the character is considered to have reached its target.
|
||||||
static constexpr float kWalkThreshold = 0.05f;
|
static constexpr float kWalkThreshold = 0.05f;
|
||||||
|
|
||||||
// --- Identity ---
|
// --- Identity ---
|
||||||
std::string npcId;
|
|
||||||
std::string npcName;
|
std::string npcName;
|
||||||
bool isPlayer = false;
|
bool isPlayer = false;
|
||||||
bool enabled = true;
|
bool enabled = true;
|
||||||
@@ -84,9 +87,11 @@ public:
|
|||||||
// Weapon visibility toggle (changes during animation)
|
// Weapon visibility toggle (changes during animation)
|
||||||
bool showWeapon = true;
|
bool showWeapon = true;
|
||||||
|
|
||||||
// --- Index-based character references (replace raw pointers) ---
|
// --- Stable character references ---
|
||||||
int attackTargetIndex = kNoTarget;
|
// Empty means no target. "player" is the campaign player; NPC IDs are
|
||||||
int faceTargetIndex = kNoTarget;
|
// location-local and validated while loading location content.
|
||||||
|
std::string attackTargetId;
|
||||||
|
std::string faceTargetId;
|
||||||
|
|
||||||
// --- Home position for drift recovery ---
|
// --- Home position for drift recovery ---
|
||||||
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero();
|
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero();
|
||||||
@@ -97,6 +102,13 @@ public:
|
|||||||
float slowMoActiveTime = 6.0f;
|
float slowMoActiveTime = 6.0f;
|
||||||
float slowMoTransitionTime = 0.5f;
|
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 ---
|
// --- Movement / visual parameters ---
|
||||||
float walkSpeed = 3.0f;
|
float walkSpeed = 3.0f;
|
||||||
float rotationSpeed = 4.0f;
|
float rotationSpeed = 4.0f;
|
||||||
@@ -115,30 +127,58 @@ public:
|
|||||||
// Internal drift-check timer (serializable so it survives save/load).
|
// Internal drift-check timer (serializable so it survives save/load).
|
||||||
float homeDriftCheckTimer = 5.0f;
|
float homeDriftCheckTimer = 5.0f;
|
||||||
|
|
||||||
// --- Non-serializable callbacks (re-wired after each load) ---
|
// Global Lua function called when the character reaches the final waypoint.
|
||||||
std::function<void()> onArrivedCallback;
|
// The name is serialized, so no runtime callback needs to be rehydrated.
|
||||||
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.
|
|
||||||
std::string onArrivedCallbackName;
|
std::string onArrivedCallbackName;
|
||||||
|
|
||||||
// --- Asset / creation info (used to reload visuals from a saved state) ---
|
// --- Asset / creation info (used to reload visuals from a saved state) ---
|
||||||
CharacterCreationInfo creationInfo;
|
CharacterCreationInfo creationInfo;
|
||||||
|
|
||||||
// --- Pure-state methods ---
|
// --- Pure-state methods ---
|
||||||
|
void configureAsPlayer();
|
||||||
bool isMoving() const;
|
bool isMoving() const;
|
||||||
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
|
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
|
||||||
Eigen::Vector3f getCurrentNavigationTarget() const;
|
Eigen::Vector3f getCurrentNavigationTarget() const;
|
||||||
void stopInPlace();
|
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; }
|
float getHp() const { return hp; }
|
||||||
void setHp(float newHp);
|
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) ---
|
// --- Serialisation (runtime mutable fields only; creation info is reloaded from config) ---
|
||||||
void save(nlohmann::json& out) const override;
|
void save(nlohmann::json& out) const override;
|
||||||
void load(const nlohmann::json& in) 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
|
} // namespace FRG
|
||||||
+2
-2
@@ -21,8 +21,8 @@ int Environment::width = CONST_DEFAULT_WIDTH;
|
|||||||
int Environment::height = CONST_DEFAULT_HEIGHT;
|
int Environment::height = CONST_DEFAULT_HEIGHT;
|
||||||
float Environment::zoom = DEFAULT_ZOOM;
|
float Environment::zoom = DEFAULT_ZOOM;
|
||||||
|
|
||||||
//bool Environment::enableLogging = false;
|
bool Environment::enableLogging = false;
|
||||||
bool Environment::enableLogging = true;
|
//bool Environment::enableLogging = true;
|
||||||
|
|
||||||
SDL_Window* Environment::window = nullptr;
|
SDL_Window* Environment::window = nullptr;
|
||||||
|
|
||||||
|
|||||||
+341
-198
@@ -1,4 +1,5 @@
|
|||||||
#include "Game.h"
|
#include "Game.h"
|
||||||
|
#include "game_server/NetworkProtocol.h"
|
||||||
#include "AnimatedModel.h"
|
#include "AnimatedModel.h"
|
||||||
#include "Localization.h"
|
#include "Localization.h"
|
||||||
#include "utils/Utils.h"
|
#include "utils/Utils.h"
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <type_traits>
|
||||||
#ifdef __ANDROID__
|
#ifdef __ANDROID__
|
||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
#endif
|
#endif
|
||||||
@@ -76,16 +78,19 @@ namespace FRG
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
Location* Game::currentLocation() const {
|
Location* Game::currentLocation() const {
|
||||||
if (gameState.currentLocationName.empty()) return nullptr;
|
const auto state = stateSnapshot();
|
||||||
auto it = gameState.locations.find(gameState.currentLocationName);
|
if (!state || state->currentLocationId.empty()) return nullptr;
|
||||||
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
|
return gameServerHost.runtimeLocation(state->currentLocationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Game::Game()
|
Game::Game()
|
||||||
: newTickCount(0)
|
: audioPlayer(std::make_unique<AudioPlayerAsync>())
|
||||||
|
, internalGameServer()
|
||||||
|
, localGameServerHost(internalGameServer)
|
||||||
|
, gameServer(internalGameServer)
|
||||||
|
, gameServerHost(localGameServerHost)
|
||||||
|
, menuManager(renderer, gameServer, *audioPlayer)
|
||||||
, lastTickCount(0)
|
, 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);
|
CampaignRegistry::instance().loadFromJson("resources/config/campaigns.json", CONST_ZIP_FILE);
|
||||||
|
|
||||||
loadSteps.push([this]() {
|
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.
|
// Wire phone dialogue start function so MenuManager can trigger dialogues.
|
||||||
menuManager.startDialogueFunc = [this](const std::string& id) {
|
menuManager.startDialogueFunc = [this](const std::string& id) {
|
||||||
if (currentLocation()) currentLocation()->dialogueSystem.startDialogue(id);
|
if (currentLocation()) currentLocation()->dialogueSystem.startDialogue(id);
|
||||||
@@ -391,15 +383,11 @@ namespace FRG
|
|||||||
menuManager.skipCutsceneFunc = [this]() {
|
menuManager.skipCutsceneFunc = [this]() {
|
||||||
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
|
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
|
||||||
};
|
};
|
||||||
|
|
||||||
menuManager.callTaxiFunc = [this]() {
|
|
||||||
if (currentLocation()) currentLocation()->onTaxiCalled();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadSteps.push([this]() {
|
loadSteps.push([this]() {
|
||||||
try {
|
try {
|
||||||
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
|
menuManager.setup(CONST_ZIP_FILE);
|
||||||
logger() << "UI loaded successfully" << std::endl;
|
logger() << "UI loaded successfully" << std::endl;
|
||||||
}
|
}
|
||||||
catch (const std::exception& e) {
|
catch (const std::exception& e) {
|
||||||
@@ -414,7 +402,8 @@ namespace FRG
|
|||||||
performResetToInitialState();
|
performResetToInitialState();
|
||||||
applyTutorialSetting();
|
applyTutorialSetting();
|
||||||
const CampaignConfig& campaign = activeCampaign();
|
const CampaignConfig& campaign = activeCampaign();
|
||||||
gameState.currentLocationName = campaign.startLocation;
|
server().submit(
|
||||||
|
SetCurrentLocationCommand{campaign.startLocation});
|
||||||
menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state
|
menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state
|
||||||
if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback();
|
if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback();
|
||||||
audioPlayer->crossFadeMusicAsync(campaign.startMusic);
|
audioPlayer->crossFadeMusicAsync(campaign.startMusic);
|
||||||
@@ -505,7 +494,10 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CampaignConfig& Game::activeCampaign() const {
|
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) campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId);
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
static const CampaignConfig empty;
|
static const CampaignConfig empty;
|
||||||
@@ -515,7 +507,8 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
|
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,
|
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
|
||||||
@@ -555,48 +548,83 @@ namespace FRG
|
|||||||
|
|
||||||
void Game::loadCampaign(const std::string& campaignId) {
|
void Game::loadCampaign(const std::string& campaignId) {
|
||||||
loadedCampaignId = campaignId;
|
loadedCampaignId = campaignId;
|
||||||
gameState.campaignId = campaignId;
|
const CampaignConfig* campaign =
|
||||||
gameState.storedLocationStates.clear();
|
CampaignRegistry::instance().find(campaignId);
|
||||||
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
|
if (!campaign) return;
|
||||||
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
|
#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.
|
// 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)
|
if (player)
|
||||||
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
void Game::unloadCampaign() {
|
void Game::unloadCampaign() {
|
||||||
unloadLocation(false);
|
unloadLocation(false);
|
||||||
player.reset();
|
player.reset();
|
||||||
loadedCampaignId.clear();
|
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
|
renderer.textureManager.UnloadUnused(); // the player's textures
|
||||||
}
|
}
|
||||||
|
|
||||||
void Game::loadLocation(const std::string& locationId) {
|
void Game::loadLocation(const std::string& locationId) {
|
||||||
const LocationConfig* config = activeCampaign().findLocation(locationId);
|
const LocationConfig* config = activeCampaign().findLocation(locationId);
|
||||||
if (!config) {
|
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;
|
<< locationId << "'" << std::endl;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
SDL_PumpEvents();
|
||||||
|
const bool initializeLocation = gameServerHost.prepareLocation(*config);
|
||||||
std::shared_ptr<Location> loc = createLocation(*config);
|
std::shared_ptr<Location> loc = createLocation(*config);
|
||||||
wireLocation(loc.get());
|
wireLocation(loc.get());
|
||||||
if (shadowMap) loc->shadowMap = shadowMap;
|
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).
|
// Back to how the player left it (or to the start state's snapshot of it).
|
||||||
auto stored = gameState.storedLocationStates.find(locationId);
|
gameServerHost.bindRuntimeLocation(locationId, loc);
|
||||||
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
|
|
||||||
gameState.locations[locationId] = loc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Game::unloadLocation(bool storeState) {
|
void Game::unloadLocation(bool storeState) {
|
||||||
for (auto& [name, loc] : gameState.locations) {
|
gameServerHost.clearRuntimeLocations(storeState);
|
||||||
if (!storeState) continue;
|
|
||||||
nlohmann::json locJson;
|
|
||||||
loc->save(locJson);
|
|
||||||
gameState.storedLocationStates[name] = std::move(locJson);
|
|
||||||
}
|
|
||||||
gameState.locations.clear();
|
|
||||||
// Textures only the location's models and characters used are freed now.
|
// Textures only the location's models and characters used are freed now.
|
||||||
renderer.textureManager.UnloadUnused();
|
renderer.textureManager.UnloadUnused();
|
||||||
}
|
}
|
||||||
@@ -614,61 +642,39 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
// Runs from inside the current location's update(); the location itself is
|
// Runs from inside the current location's update(); the location itself is
|
||||||
// released by a later loading step.
|
// released by a later loading step.
|
||||||
enterLocationThen(gameState.campaignId, destName, [this, destName, destPos, destRotY]() {
|
const auto state = stateSnapshot();
|
||||||
gameState.currentLocationName = destName;
|
if (!state) return;
|
||||||
|
enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() {
|
||||||
|
server().submit(SetCurrentLocationCommand{destName});
|
||||||
Location* loc = currentLocation();
|
Location* loc = currentLocation();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
if (loc->player) {
|
if (loc->player) {
|
||||||
loc->player->state.position = destPos;
|
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
|
||||||
loc->player->setTarget(destPos);
|
workingState.position = destPos;
|
||||||
loc->player->stopInPlace();
|
workingState.facingAngle = destRotY;
|
||||||
loc->player->state.facingAngle = destRotY;
|
workingState.targetFacingAngle = destRotY;
|
||||||
loc->player->state.targetFacingAngle = destRotY;
|
});
|
||||||
|
loc->stopCharacter(*loc->player);
|
||||||
}
|
}
|
||||||
loc->state.cameraAzimuth = destRotY;
|
loc->localState().cameraAzimuth = destRotY;
|
||||||
loc->state.isDarklands = gameState.isDarklands;
|
|
||||||
loc->state.isNight = gameState.isNight;
|
|
||||||
loc->state.isDawn = gameState.isDawn;
|
|
||||||
loc->scriptEngine.callLocationEnterCallback();
|
loc->scriptEngine.callLocationEnterCallback();
|
||||||
|
|
||||||
//Reset taxi
|
//Reset taxi
|
||||||
loc->state.taxiCar = loc->state.taxiCarInitialState;
|
server().submit(ResetTaxiCommand{destName});
|
||||||
loc->state.taxiArrivedCallbackCalled = false;
|
server().submit(SetTaxiCalledCommand{false});
|
||||||
gameState.taxiIsCalled = false;
|
|
||||||
|
|
||||||
menuManager.onLocationChanged(destName);
|
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
|
// 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.
|
// any more, so they are built fresh when the player first goes there.
|
||||||
gameState.storedLocationStates.clear();
|
std::vector<std::string> loadedIds =
|
||||||
std::vector<std::string> loadedIds;
|
gameServerHost.loadedRuntimeLocationIds();
|
||||||
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
|
gameServerHost.resetCampaign(
|
||||||
|
activeCampaign().questsPath, CONST_ZIP_FILE);
|
||||||
unloadLocation(false);
|
unloadLocation(false);
|
||||||
for (const std::string& id : loadedIds) loadLocation(id);
|
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.
|
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
|
||||||
// Re-comment before shipping.
|
// Re-comment before shipping.
|
||||||
//saveInitialState();
|
//saveInitialState();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Game::performResetToInitialState() {
|
void Game::performResetToInitialState() {
|
||||||
@@ -698,15 +705,8 @@ namespace FRG
|
|||||||
if (!content.empty()) {
|
if (!content.empty()) {
|
||||||
try {
|
try {
|
||||||
nlohmann::json root = nlohmann::json::parse(content);
|
nlohmann::json root = nlohmann::json::parse(content);
|
||||||
const std::string campaignId = gameState.campaignId;
|
gameServerHost.load(root); // applies to the loaded location in place and stores
|
||||||
// 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
|
|
||||||
// the other locations' states for when they load.
|
// the other locations' states for when they load.
|
||||||
gameState.campaignId = campaignId; // the snapshot may predate campaigns
|
|
||||||
return;
|
return;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
logger() << "[reset] Failed to parse " << path << ": " << e.what()
|
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.
|
// (if the campaign has one); a disabled tutorial is simply one that is already completed.
|
||||||
void Game::applyTutorialSetting() {
|
void Game::applyTutorialSetting() {
|
||||||
if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting)
|
if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting)
|
||||||
gameState.tutorialStep = TutorialStep::Completed;
|
server().submit(
|
||||||
|
SetTutorialStepCommand{TutorialStep::Completed});
|
||||||
}
|
}
|
||||||
|
|
||||||
void Game::saveInitialState() {
|
void Game::saveInitialState() {
|
||||||
nlohmann::json root;
|
nlohmann::json root;
|
||||||
gameState.save(root);
|
gameServerHost.save(root);
|
||||||
// Written next to the executable; copy it over the campaign's start-state file.
|
// Written next to the executable; copy it over the campaign's start-state file.
|
||||||
const std::string startStatePath = activeCampaign().startStatePath;
|
const std::string startStatePath = activeCampaign().startStatePath;
|
||||||
const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1);
|
const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1);
|
||||||
@@ -752,7 +753,7 @@ namespace FRG
|
|||||||
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
|
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
|
||||||
params.npcsJsonPath = config.npcsJsonPath;
|
params.npcsJsonPath = config.npcsJsonPath;
|
||||||
params.dialoguesJsonPath = config.dialoguesJsonPath;
|
params.dialoguesJsonPath = config.dialoguesJsonPath;
|
||||||
params.navigationJsonPaths = config.navigationJsonPaths;
|
//params.navigationJsonPaths = config.navigationJsonPaths;
|
||||||
params.scriptPath = config.scriptPath;
|
params.scriptPath = config.scriptPath;
|
||||||
params.teleportsJsonPath = config.teleportsJsonPath;
|
params.teleportsJsonPath = config.teleportsJsonPath;
|
||||||
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
|
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
|
||||||
@@ -760,27 +761,30 @@ namespace FRG
|
|||||||
params.playerPosition = config.playerPosition;
|
params.playerPosition = config.playerPosition;
|
||||||
params.player = player;
|
params.player = player;
|
||||||
|
|
||||||
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
|
auto loc = std::make_shared<Location>(
|
||||||
loc->setup(params, &gameState.questJournal, audioPlayer.get());
|
renderer, server(), config.id);
|
||||||
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
|
gameServerHost.setupRuntimeLocation(
|
||||||
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
|
config.id, loc, std::move(params), audioPlayer.get());
|
||||||
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
|
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
|
||||||
gameState.isNight = isNight;
|
const auto state = stateSnapshot();
|
||||||
gameState.isDawn = isDawn;
|
if (!state) return;
|
||||||
|
server().submit(SetWorldModeCommand{
|
||||||
|
state->isDarklands, isNight, isDawn});
|
||||||
};
|
};
|
||||||
loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
|
loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
|
||||||
loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
|
loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
|
||||||
loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
|
loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
|
||||||
loc->requestReturnToMainMenu = [this]() {
|
loc->requestReturnToMainMenu = [this]() {
|
||||||
gameState.currentLocationName.clear();
|
server().submit(SetCurrentLocationCommand{std::string()});
|
||||||
menuManager.showMenuAfterGameCompleted();
|
menuManager.showMenuAfterGameCompleted();
|
||||||
};
|
};
|
||||||
loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) {
|
loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) {
|
||||||
menuManager.setChatUnread(idx, unread, msg);
|
menuManager.setChatUnread(idx, unread, msg);
|
||||||
};
|
};
|
||||||
for (auto& npc : loc->npcs) {
|
for (auto& npc : loc->npcs) {
|
||||||
if (npc && npc->state.canAttack) {
|
auto state = npc ? npc->stateSnapshot() : nullptr;
|
||||||
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
|
if (state && state->canAttack) {
|
||||||
|
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loc->requestDarklandsPlayBattleMusic = [this]() {
|
loc->requestDarklandsPlayBattleMusic = [this]() {
|
||||||
@@ -792,26 +796,6 @@ namespace FRG
|
|||||||
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
|
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;
|
return loc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,20 +810,16 @@ namespace FRG
|
|||||||
teleportPlayer(destName, destPos, destRotY);
|
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.
|
// Wire tutorial advance callbacks.
|
||||||
// completeTutorialStep() only acts on the current step, so sharing is safe.
|
// completeTutorialStep() only acts on the current step, so sharing is safe.
|
||||||
{
|
{
|
||||||
loc->dialogueSystem.setOnDialogueAdvanced([this]() {
|
loc->dialogueSystem.setOnDialogueAdvanced([this]() {
|
||||||
gameState.completeTutorialStep(TutorialStep::AdvanceDialogue);
|
server().submit(
|
||||||
|
CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue});
|
||||||
});
|
});
|
||||||
loc->onPlayerFloorWalk = [this]() {
|
loc->onPlayerFloorWalk = [this]() {
|
||||||
gameState.completeTutorialStep(TutorialStep::Walk);
|
server().submit(
|
||||||
|
CompleteTutorialStepCommand{TutorialStep::Walk});
|
||||||
menuManager.onPlayerStartedWalking();
|
menuManager.onPlayerStartedWalking();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -866,24 +846,34 @@ namespace FRG
|
|||||||
if (loc->player) {
|
if (loc->player) {
|
||||||
// Raw pointer: the player belongs to the location (and outlives it), so a
|
// 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.
|
// 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);
|
menuManager.updateHealthBar(hp, maxHp);
|
||||||
|
|
||||||
if (attackerIndex != CharacterState::kNoTarget) {
|
if (!attackerId.empty()) {
|
||||||
//Someone attacked, cancel dialogue and agro the player
|
//Someone attacked, cancel dialogue and agro the player
|
||||||
|
|
||||||
if (loc->dialogueSystem.isDialogueActive()) {
|
if (loc->dialogueSystem.isDialogueActive()) {
|
||||||
loc->dialogueSystem.stopDialogue();
|
loc->dialogueSystem.stopDialogue();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
|
bool currentTargetDead = false;
|
||||||
((loc->player->state.attackTargetIndex >= 0) &&
|
auto playerState = loc->player->stateSnapshot();
|
||||||
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
|
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->isMoving() == false
|
||||||
&&
|
&&
|
||||||
loc->player->getHp() > 0) {
|
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__);
|
CheckGlError(__FILE__, __LINE__);
|
||||||
logger() << "Shadow map initialized" << std::endl;
|
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()
|
void Game::destroyShadowMapAndProparateToLocations()
|
||||||
@@ -915,7 +907,9 @@ namespace FRG
|
|||||||
shadowMap.reset();
|
shadowMap.reset();
|
||||||
CheckGlError(__FILE__, __LINE__);
|
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__);
|
CheckGlError(__FILE__, __LINE__);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -988,16 +982,12 @@ namespace FRG
|
|||||||
Location* loc = currentLocation();
|
Location* loc = currentLocation();
|
||||||
if (loc)
|
if (loc)
|
||||||
{
|
{
|
||||||
// Sync global flags so Location's draw functions see them.
|
const auto state = stateSnapshot();
|
||||||
loc->state.isDarklands = gameState.isDarklands;
|
if (state && state->isDarklands) {
|
||||||
loc->state.isNight = gameState.isNight;
|
|
||||||
loc->state.isDawn = gameState.isDawn;
|
|
||||||
|
|
||||||
if (gameState.isDarklands) {
|
|
||||||
loc->drawGameDarklands();
|
loc->drawGameDarklands();
|
||||||
CheckGlError(__FILE__, __LINE__);
|
CheckGlError(__FILE__, __LINE__);
|
||||||
}
|
}
|
||||||
else if (gameState.isNight) {
|
else if (state && state->isNight) {
|
||||||
loc->drawGameNight();
|
loc->drawGameNight();
|
||||||
CheckGlError(__FILE__, __LINE__);
|
CheckGlError(__FILE__, __LINE__);
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1132,7 @@ namespace FRG
|
|||||||
|
|
||||||
int64_t Game::getSyncTimeMs() {
|
int64_t Game::getSyncTimeMs() {
|
||||||
int64_t localNow = std::chrono::duration_cast<std::chrono::milliseconds>(
|
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;
|
return localNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,21 +1141,15 @@ namespace FRG
|
|||||||
|
|
||||||
if (lastTickCount == 0) {
|
if (lastTickCount == 0) {
|
||||||
lastTickCount = getSyncTimeMs();
|
lastTickCount = getSyncTimeMs();
|
||||||
|
|
||||||
lastTickCount = (lastTickCount / 50) * 50;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
newTickCount = getSyncTimeMs();
|
const int64_t now = getSyncTimeMs();
|
||||||
|
const int64_t delta =
|
||||||
newTickCount = (newTickCount / 50) * 50;
|
(std::min)(now - lastTickCount,
|
||||||
|
static_cast<int64_t>(CONST_MAX_TIME_INTERVAL));
|
||||||
if (newTickCount - lastTickCount > CONST_TIMER_INTERVAL) {
|
lastTickCount = now;
|
||||||
|
if (delta > 0) {
|
||||||
int64_t delta = newTickCount - lastTickCount;
|
|
||||||
|
|
||||||
lastTickCount = newTickCount;
|
|
||||||
|
|
||||||
updateDarklandsFlash(delta);
|
updateDarklandsFlash(delta);
|
||||||
menuManager.uiManager.update(static_cast<float>(delta));
|
menuManager.uiManager.update(static_cast<float>(delta));
|
||||||
@@ -1175,14 +1159,115 @@ namespace FRG
|
|||||||
if (!menuManager.isMainMenuOpen())
|
if (!menuManager.isMainMenuOpen())
|
||||||
menuManager.update(static_cast<float>(delta));
|
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 (Location* location = currentLocation())
|
||||||
|
location->updatePresentation(delta);
|
||||||
|
#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() {
|
void Game::render() {
|
||||||
FRG::CheckGlError(__FILE__, __LINE__);
|
FRG::CheckGlError(__FILE__, __LINE__);
|
||||||
|
|
||||||
@@ -1228,6 +1313,7 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Game::update() {
|
void Game::update() {
|
||||||
|
processServerEvents();
|
||||||
SDL_Event event;
|
SDL_Event event;
|
||||||
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
|
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
|
||||||
while (SDL_PollEvent(&event)) {
|
while (SDL_PollEvent(&event)) {
|
||||||
@@ -1332,8 +1418,9 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
else if (event.button.button == SDL_BUTTON_RIGHT
|
else if (event.button.button == SDL_BUTTON_RIGHT
|
||||||
&& event.type == SDL_MOUSEBUTTONUP
|
&& event.type == SDL_MOUSEBUTTONUP
|
||||||
&& editorMode == EditorMode::Navigation
|
&& currentLocation()
|
||||||
&& currentLocation()) {
|
&& currentLocation()->editorMode == EditorMode::Navigation
|
||||||
|
) {
|
||||||
currentLocation()->editor.handleRightClick();
|
currentLocation()->editor.handleRightClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1361,7 +1448,8 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
//logger() << "Current zoom: " << Environment::zoom << std::endl;
|
//logger() << "Current zoom: " << Environment::zoom << std::endl;
|
||||||
// Tutorial: any mouse-wheel scroll counts as the "zoom gesture".
|
// 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) {
|
if (event.type == SDL_KEYDOWN && event.key.repeat == 0) {
|
||||||
@@ -1378,15 +1466,29 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
if (event.key.keysym.sym == SDLK_1)
|
if (event.key.keysym.sym == SDLK_1)
|
||||||
{
|
{
|
||||||
startNightTransition();
|
currentLocation()->switchNavigation(0);
|
||||||
|
//startNightTransition();
|
||||||
}
|
}
|
||||||
if (event.key.keysym.sym == SDLK_2)
|
if (event.key.keysym.sym == SDLK_2)
|
||||||
{
|
{
|
||||||
startDarklandsTransition();
|
currentLocation()->switchNavigation(1);
|
||||||
|
//startDarklandsTransition();
|
||||||
}
|
}
|
||||||
if (event.key.keysym.sym == SDLK_3)
|
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 +1540,18 @@ namespace FRG
|
|||||||
if (!currentLocation()) return;
|
if (!currentLocation()) return;
|
||||||
|
|
||||||
if (currentLocation()->player) {
|
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) {
|
for (auto& npc : currentLocation()->npcs) {
|
||||||
if (npc) {
|
if (npc) {
|
||||||
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
|
currentLocation()->updateCharacterState(
|
||||||
|
*npc, [](CharacterState& workingState) {
|
||||||
|
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1460,8 +1568,9 @@ namespace FRG
|
|||||||
currentLocation()->lastMouseY = eventY;
|
currentLocation()->lastMouseY = eventY;
|
||||||
|
|
||||||
// Snapshot current angles so we can measure how far the user rotates.
|
// Snapshot current angles so we can measure how far the user rotates.
|
||||||
dragStartAzimuth = currentLocation()->state.cameraAzimuth;
|
const LocationLocalState& state = currentLocation()->localState();
|
||||||
dragStartInclination = currentLocation()->state.cameraInclination;
|
dragStartAzimuth = state.cameraAzimuth;
|
||||||
|
dragStartInclination = state.cameraInclination;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,7 +1630,8 @@ namespace FRG
|
|||||||
|
|
||||||
// Tutorial: detect a significant pinch-zoom (≥ 2 zoom units).
|
// Tutorial: detect a significant pinch-zoom (≥ 2 zoom units).
|
||||||
if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) {
|
if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) {
|
||||||
gameState.completeTutorialStep(TutorialStep::Zoom);
|
server().submit(
|
||||||
|
CompleteTutorialStepCommand{TutorialStep::Zoom});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1664,12 +1774,16 @@ namespace FRG
|
|||||||
// ~0.15 rad (≈8.6°) per axis ensures the user intentionally panned in 2D,
|
// ~0.15 rad (≈8.6°) per axis ensures the user intentionally panned in 2D,
|
||||||
// not just nudged a single axis by accident.
|
// not just nudged a single axis by accident.
|
||||||
static constexpr float TUTORIAL_ROTATION_THRESHOLD = 0.15f;
|
static constexpr float TUTORIAL_ROTATION_THRESHOLD = 0.15f;
|
||||||
|
const auto game = stateSnapshot();
|
||||||
if (cameraDragging
|
if (cameraDragging
|
||||||
&& gameState.tutorialStep == TutorialStep::RotateCamera) {
|
&& game
|
||||||
float deltaAz = std::abs(currentLocation()->state.cameraAzimuth - dragStartAzimuth);
|
&& game->tutorialStep == TutorialStep::RotateCamera) {
|
||||||
float deltaInc = std::abs(currentLocation()->state.cameraInclination - dragStartInclination);
|
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) {
|
if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) {
|
||||||
gameState.completeTutorialStep(TutorialStep::RotateCamera);
|
server().submit(
|
||||||
|
CompleteTutorialStepCommand{TutorialStep::RotateCamera});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1677,13 +1791,14 @@ namespace FRG
|
|||||||
|
|
||||||
bool Game::startDarklandsTransition()
|
bool Game::startDarklandsTransition()
|
||||||
{
|
{
|
||||||
if (!gameState.isNight)
|
const auto state = stateSnapshot();
|
||||||
|
if (!state || !state->isNight)
|
||||||
{
|
{
|
||||||
currentLocation()->dialogueSystem.startDialogue("darklands_day_dialog001");
|
currentLocation()->dialogueSystem.startDialogue("darklands_day_dialog001");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameState.isDawn)
|
if (state->isDawn)
|
||||||
{
|
{
|
||||||
currentLocation()->dialogueSystem.startDialogue("darklands_morning_dialog001");
|
currentLocation()->dialogueSystem.startDialogue("darklands_morning_dialog001");
|
||||||
return false;
|
return false;
|
||||||
@@ -1717,11 +1832,13 @@ namespace FRG
|
|||||||
if (darklandsFlashFadingIn) {
|
if (darklandsFlashFadingIn) {
|
||||||
darklandsFlashAlpha = min(darklandsFlashAlpha + step, 1.0f);
|
darklandsFlashAlpha = min(darklandsFlashAlpha + step, 1.0f);
|
||||||
if (darklandsFlashAlpha >= 1.0f) {
|
if (darklandsFlashAlpha >= 1.0f) {
|
||||||
|
const auto before = stateSnapshot();
|
||||||
|
if (!before) return;
|
||||||
if (isNightTransition) {
|
if (isNightTransition) {
|
||||||
gameState.isNight = !gameState.isNight;
|
server().submit(SetWorldModeCommand{
|
||||||
|
before->isDarklands, !before->isNight, before->isDawn});
|
||||||
if (currentLocation())
|
if (currentLocation())
|
||||||
{
|
{
|
||||||
currentLocation()->state.isNight = gameState.isNight;
|
|
||||||
currentLocation()->scriptEngine.callTriggerNightEnterCallback();
|
currentLocation()->scriptEngine.callTriggerNightEnterCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,12 +1846,15 @@ namespace FRG
|
|||||||
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
gameState.isDarklands = !gameState.isDarklands;
|
server().submit(SetWorldModeCommand{
|
||||||
|
!before->isDarklands, before->isNight, before->isDawn});
|
||||||
|
const auto after = stateSnapshot();
|
||||||
|
if (!after) return;
|
||||||
|
|
||||||
// Change HUD
|
// Change HUD
|
||||||
menuManager.setDarklandsMode(gameState.isDarklands);
|
menuManager.setDarklandsMode(after->isDarklands);
|
||||||
|
|
||||||
if (gameState.isDarklands)
|
if (after->isDarklands)
|
||||||
{
|
{
|
||||||
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
||||||
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
|
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
|
||||||
@@ -1746,8 +1866,7 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentLocation()) {
|
if (currentLocation()) {
|
||||||
currentLocation()->state.isDarklands = gameState.isDarklands;
|
if (after->isDarklands)
|
||||||
if (gameState.isDarklands)
|
|
||||||
currentLocation()->scriptEngine.callDarklandsEnterCallback();
|
currentLocation()->scriptEngine.callDarklandsEnterCallback();
|
||||||
else
|
else
|
||||||
currentLocation()->scriptEngine.callDarklandsExitCallback();
|
currentLocation()->scriptEngine.callDarklandsExitCallback();
|
||||||
@@ -1816,11 +1935,18 @@ namespace FRG
|
|||||||
if (content.empty()) return {};
|
if (content.empty()) return {};
|
||||||
try {
|
try {
|
||||||
nlohmann::json root = nlohmann::json::parse(content);
|
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;
|
SaveSlotInfo info;
|
||||||
info.empty = false;
|
info.empty = false;
|
||||||
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
|
info.campaignId =
|
||||||
info.locationName = root.value("currentLocationName", "");
|
networkState.at("campaignId").get<std::string>();
|
||||||
info.savedAt = root.value("savedAt", "");
|
info.locationName =
|
||||||
|
networkState.at("currentLocationId").get<std::string>();
|
||||||
|
info.savedAt = networkState.value("savedAt", "");
|
||||||
return info;
|
return info;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
return {};
|
return {};
|
||||||
@@ -1830,7 +1956,7 @@ namespace FRG
|
|||||||
void Game::saveGame(int slot)
|
void Game::saveGame(int slot)
|
||||||
{
|
{
|
||||||
nlohmann::json root;
|
nlohmann::json root;
|
||||||
gameState.save(root);
|
gameServerHost.save(root);
|
||||||
std::string path = "save_slot" + std::to_string(slot) + ".json";
|
std::string path = "save_slot" + std::to_string(slot) + ".json";
|
||||||
|
|
||||||
/*std::ofstream file(path);
|
/*std::ofstream file(path);
|
||||||
@@ -1858,11 +1984,26 @@ namespace FRG
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The save's campaign and location may not be loaded yet; the state is applied once they are.
|
if (root.value("persistenceVersion", -1) !=
|
||||||
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
|
kSinglePlayerPersistenceVersion ||
|
||||||
const std::string locationId = root.value("currentLocationName", std::string());
|
!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]() {
|
enterLocationThen(campaignId, locationId, [this, root]() {
|
||||||
gameState.load(root);
|
gameServerHost.load(root);
|
||||||
playMusicForLoadedGame();
|
playMusicForLoadedGame();
|
||||||
menuManager.enterGameplay(true);
|
menuManager.enterGameplay(true);
|
||||||
});
|
});
|
||||||
@@ -1870,19 +2011,21 @@ namespace FRG
|
|||||||
|
|
||||||
void Game::playMusicForLoadedGame()
|
void Game::playMusicForLoadedGame()
|
||||||
{
|
{
|
||||||
if (gameState.isDarklands)
|
const auto state = stateSnapshot();
|
||||||
|
if (!state) return;
|
||||||
|
if (state->isDarklands)
|
||||||
{
|
{
|
||||||
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
audioPlayer->crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
||||||
menuManager.lastMusicTrack = "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");
|
audioPlayer->crossFadeMusicAsync("music/bishkek night.ogg");
|
||||||
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (gameState.currentLocationName == "location_dorm") {
|
if (state->currentLocationId == "location_dorm") {
|
||||||
audioPlayer->crossFadeMusicAsync("music/obshaga.ogg");
|
audioPlayer->crossFadeMusicAsync("music/obshaga.ogg");
|
||||||
menuManager.lastMusicTrack = "music/obshaga.ogg";
|
menuManager.lastMusicTrack = "music/obshaga.ogg";
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-11
@@ -21,7 +21,14 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include "Location.h"
|
#include "Location.h"
|
||||||
#include "AudioPlayerAsync.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 {
|
namespace FRG {
|
||||||
|
|
||||||
struct LocationConfig;
|
struct LocationConfig;
|
||||||
@@ -73,9 +80,7 @@ namespace FRG {
|
|||||||
// content reload, don't have a meaningful denominator, so the bar is hidden.
|
// content reload, don't have a meaningful denominator, so the bar is hidden.
|
||||||
bool showLoadingProgressBar = true;
|
bool showLoadingProgressBar = true;
|
||||||
|
|
||||||
GameState gameState;
|
//EditorMode editorMode = EditorMode::None;
|
||||||
|
|
||||||
EditorMode editorMode = EditorMode::None;
|
|
||||||
|
|
||||||
// Returns false if a transition is already in progress.
|
// Returns false if a transition is already in progress.
|
||||||
bool startDarklandsTransition();
|
bool startDarklandsTransition();
|
||||||
@@ -83,6 +88,22 @@ namespace FRG {
|
|||||||
|
|
||||||
std::unique_ptr<AudioPlayerAsync> audioPlayer;
|
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;
|
MenuManager menuManager;
|
||||||
|
|
||||||
void activateSlowMoEffect();
|
void activateSlowMoEffect();
|
||||||
@@ -123,15 +144,16 @@ namespace FRG {
|
|||||||
|
|
||||||
|
|
||||||
Location* currentLocation() const;
|
Location* currentLocation() const;
|
||||||
|
std::shared_ptr<const GameSnapshot> stateSnapshot() const {
|
||||||
|
return gameServer.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
void saveGame(int slot);
|
void saveGame(int slot);
|
||||||
void loadGame(int slot);
|
void loadGame(int slot);
|
||||||
void playMusicForLoadedGame();
|
void playMusicForLoadedGame();
|
||||||
SaveSlotInfo readSlotInfo(int slot) const;
|
SaveSlotInfo readSlotInfo(int slot) const;
|
||||||
// Loading model: one campaign at a time, and within it only the location the player
|
// Loading model: one campaign at a time. InternalGameServer privately owns the
|
||||||
// is in (gameState.locations). Leaving a location stores its state in
|
// one live Location while GameState retains pure value state for every location.
|
||||||
// gameState.storedLocationStates and releases it; the player is created once per
|
|
||||||
// campaign and shared by its locations.
|
|
||||||
std::string loadedCampaignId;
|
std::string loadedCampaignId;
|
||||||
std::shared_ptr<Character> player;
|
std::shared_ptr<Character> player;
|
||||||
|
|
||||||
@@ -146,7 +168,7 @@ namespace FRG {
|
|||||||
void unloadCampaign();
|
void unloadCampaign();
|
||||||
// Builds the campaign's location and restores its stored state, if any.
|
// Builds the campaign's location and restores its stored state, if any.
|
||||||
void loadLocation(const std::string& locationId);
|
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);
|
void unloadLocation(bool storeState);
|
||||||
// Teleport zones: moves the player to another location of the campaign.
|
// Teleport zones: moves the player to another location of the campaign.
|
||||||
void teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY);
|
void teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY);
|
||||||
@@ -174,6 +196,7 @@ namespace FRG {
|
|||||||
|
|
||||||
int64_t getSyncTimeMs();
|
int64_t getSyncTimeMs();
|
||||||
void processTickCount();
|
void processTickCount();
|
||||||
|
void processServerEvents();
|
||||||
void drawScene();
|
void drawScene();
|
||||||
void drawUI();
|
void drawUI();
|
||||||
|
|
||||||
@@ -230,13 +253,12 @@ namespace FRG {
|
|||||||
*/
|
*/
|
||||||
//void drawMobilePortraitOverlay();
|
//void drawMobilePortraitOverlay();
|
||||||
|
|
||||||
int64_t newTickCount;
|
|
||||||
int64_t lastTickCount;
|
int64_t lastTickCount;
|
||||||
|
int64_t simulationAccumulatorMs = 0;
|
||||||
|
|
||||||
int lastDrawableW = -1;
|
int lastDrawableW = -1;
|
||||||
int lastDrawableH = -1;
|
int lastDrawableH = -1;
|
||||||
|
|
||||||
static const size_t CONST_TIMER_INTERVAL = 10;
|
|
||||||
static const size_t CONST_MAX_TIME_INTERVAL = 1000;
|
static const size_t CONST_MAX_TIME_INTERVAL = 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+74
-44
@@ -1,24 +1,12 @@
|
|||||||
#include "GameState.h"
|
#include "GameState.h"
|
||||||
|
#include "utils/Utils.h"
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
namespace FRG {
|
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)
|
bool GameState::completeTutorialStep(TutorialStep step)
|
||||||
{
|
{
|
||||||
if (tutorialStep != step || step == TutorialStep::Completed) return false;
|
if (tutorialStep != step || step == TutorialStep::Completed) return false;
|
||||||
@@ -26,8 +14,29 @@ bool GameState::completeTutorialStep(TutorialStep step)
|
|||||||
return true;
|
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
|
void GameState::save(nlohmann::json& out) const
|
||||||
{
|
{
|
||||||
|
out["schemaVersion"] = kSchemaVersion;
|
||||||
// Timestamp
|
// Timestamp
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
std::tm* tm = std::localtime(&now);
|
std::tm* tm = std::localtime(&now);
|
||||||
@@ -37,7 +46,7 @@ void GameState::save(nlohmann::json& out) const
|
|||||||
|
|
||||||
// --- World ---
|
// --- World ---
|
||||||
out["campaignId"] = campaignId;
|
out["campaignId"] = campaignId;
|
||||||
out["currentLocationName"] = currentLocationName;
|
out["currentLocationId"] = currentLocationName;
|
||||||
out["isDarklands"] = isDarklands;
|
out["isDarklands"] = isDarklands;
|
||||||
out["isNight"] = isNight;
|
out["isNight"] = isNight;
|
||||||
out["isDawn"] = isDawn;
|
out["isDawn"] = isDawn;
|
||||||
@@ -87,16 +96,21 @@ void GameState::save(nlohmann::json& out) const
|
|||||||
questJournal.save(questJson);
|
questJournal.save(questJson);
|
||||||
out["questJournal"] = std::move(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 ---
|
// --- Locations ---
|
||||||
// Unloaded locations keep their last stored state; loaded ones are saved live.
|
|
||||||
nlohmann::json locsJson = nlohmann::json::object();
|
nlohmann::json locsJson = nlohmann::json::object();
|
||||||
for (const auto& [name, stored] : storedLocationStates) locsJson[name] = stored;
|
for (const auto& [name, location] : locations) {
|
||||||
for (const auto& [name, loc] : locations) {
|
nlohmann::json locJson;
|
||||||
if (loc) {
|
location.save(locJson);
|
||||||
nlohmann::json locJson;
|
locsJson[name] = std::move(locJson);
|
||||||
loc->save(locJson);
|
|
||||||
locsJson[name] = std::move(locJson);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out["locations"] = std::move(locsJson);
|
out["locations"] = std::move(locsJson);
|
||||||
|
|
||||||
@@ -105,12 +119,16 @@ void GameState::save(nlohmann::json& out) const
|
|||||||
|
|
||||||
void GameState::load(const nlohmann::json& in)
|
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;
|
//logger() << "GameState::load step 1" << std::endl;
|
||||||
|
|
||||||
// --- World ---
|
// --- World ---
|
||||||
// Literal default: saves made before campaigns existed belong to the main game.
|
campaignId = in.at("campaignId").get<std::string>();
|
||||||
campaignId = in.value("campaignId", std::string("main"));
|
currentLocationName = in.at("currentLocationId").get<std::string>();
|
||||||
currentLocationName = in.value("currentLocationName", currentLocationName);
|
|
||||||
isDarklands = in.value("isDarklands", isDarklands);
|
isDarklands = in.value("isDarklands", isDarklands);
|
||||||
isNight = in.value("isNight", isNight);
|
isNight = in.value("isNight", isNight);
|
||||||
isDawn = in.value("isDawn", isDawn);
|
isDawn = in.value("isDawn", isDawn);
|
||||||
@@ -142,11 +160,8 @@ void GameState::load(const nlohmann::json& in)
|
|||||||
//logger() << "GameState::load step 5" << std::endl;
|
//logger() << "GameState::load step 5" << std::endl;
|
||||||
|
|
||||||
// --- Tutorial ---
|
// --- Tutorial ---
|
||||||
if (in.contains("uniIntTutorialState")) {
|
tutorialStep = static_cast<TutorialStep>(
|
||||||
tutorialStep = migrateLegacyTutorialStep(in.value("tutorialStep", 0), in["uniIntTutorialState"].get<int>());
|
in.at("tutorialStep").get<int>());
|
||||||
} else {
|
|
||||||
tutorialStep = static_cast<TutorialStep>(in.value("tutorialStep", static_cast<int>(tutorialStep)));
|
|
||||||
}
|
|
||||||
tutorialPhoneChatScreenOpened = in.value("tutorialPhoneChatScreenOpened", tutorialPhoneChatScreenOpened);
|
tutorialPhoneChatScreenOpened = in.value("tutorialPhoneChatScreenOpened", tutorialPhoneChatScreenOpened);
|
||||||
tutorialJournalScreenOpened = in.value("tutorialJournalScreenOpened", tutorialJournalScreenOpened);
|
tutorialJournalScreenOpened = in.value("tutorialJournalScreenOpened", tutorialJournalScreenOpened);
|
||||||
tutorialNeedOpenTaxiScreen = in.value("tutorialNeedOpenTaxiScreen", tutorialNeedOpenTaxiScreen);
|
tutorialNeedOpenTaxiScreen = in.value("tutorialNeedOpenTaxiScreen", tutorialNeedOpenTaxiScreen);
|
||||||
@@ -193,20 +208,35 @@ void GameState::load(const nlohmann::json& in)
|
|||||||
|
|
||||||
logger() << "GameState::load step 10" << std::endl;
|
logger() << "GameState::load step 10" << std::endl;
|
||||||
|
|
||||||
// --- Locations ---
|
// --- Players ---
|
||||||
// Every location's state is stored (applied when the location loads); the ones
|
const auto& playerValues = in.at("players");
|
||||||
// already loaded get it right away.
|
if (!playerValues.is_object())
|
||||||
storedLocationStates.clear();
|
throw std::runtime_error("Game state players must be an object");
|
||||||
if (in.contains("locations") && in["locations"].is_object()) {
|
for (const auto& [id, value] : playerValues.items()) {
|
||||||
for (const auto& [name, locJson] : in["locations"].items()) {
|
auto& player = players[id];
|
||||||
//logger() << "GameState::load step 10.1: " << name << std::endl;
|
if (!player) player = std::make_shared<CharacterState>();
|
||||||
storedLocationStates[name] = locJson;
|
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);
|
// --- Locations ---
|
||||||
if (it != locations.end() && it->second) {
|
const auto& locationValues = in.at("locations");
|
||||||
it->second->load(locJson);
|
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;
|
//logger() << "GameState::load step 11" << std::endl;
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "items/Item.h"
|
#include "items/Item.h"
|
||||||
#include "quest/QuestJournal.h"
|
#include "quest/QuestJournal.h"
|
||||||
#include "Location.h"
|
#include "LocationGameState.h"
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -32,17 +32,17 @@ struct StoredChatMessage {
|
|||||||
bool incoming;
|
bool incoming;
|
||||||
};
|
};
|
||||||
|
|
||||||
using LocationMap = std::unordered_map<std::string, std::shared_ptr<Location>>;
|
|
||||||
|
|
||||||
struct GameState : public ISaveable {
|
struct GameState : public ISaveable {
|
||||||
|
static constexpr int kSchemaVersion = 2;
|
||||||
|
|
||||||
// --- World ---
|
// --- World ---
|
||||||
// Campaign being played (see CampaignRegistry).
|
// Campaign being played (see CampaignRegistry).
|
||||||
std::string campaignId = "main";
|
std::string campaignId = "main";
|
||||||
// The campaign's loaded location(s); only the one the player is in is kept loaded.
|
// Pure authoritative location data. Live Location/render/script instances are
|
||||||
LocationMap locations;
|
// private runtime storage in InternalGameServer and never enter GameState.
|
||||||
// Saved state (Location::save) of the campaign's locations that are not loaded,
|
std::unordered_map<std::string, LocationGameState> locations;
|
||||||
// applied when one is loaded again. save()/load() cover both maps.
|
// Players are campaign-level entities and remain stable across locations.
|
||||||
std::unordered_map<std::string, nlohmann::json> storedLocationStates;
|
PlayerStateMap players;
|
||||||
std::string currentLocationName;
|
std::string currentLocationName;
|
||||||
bool isDarklands = false;
|
bool isDarklands = false;
|
||||||
bool isNight = 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.
|
// Moves to the next step if `step` is the current one; returns whether it did.
|
||||||
bool completeTutorialStep(TutorialStep step);
|
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 ---
|
// --- Phone / chat ---
|
||||||
bool chatUnread[3] = { true, true, true };
|
bool chatUnread[3] = { true, true, true };
|
||||||
std::string chatPreviewMsg[3];
|
std::string chatPreviewMsg[3];
|
||||||
|
|||||||
+770
-867
File diff suppressed because it is too large.
Load diff
+85
-47
@@ -1,4 +1,4 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "render/Renderer.h"
|
#include "render/Renderer.h"
|
||||||
#include "Environment.h"
|
#include "Environment.h"
|
||||||
@@ -16,13 +16,16 @@
|
|||||||
#include "TeleportZone.h"
|
#include "TeleportZone.h"
|
||||||
#include "LocationEditor.h"
|
#include "LocationEditor.h"
|
||||||
#include "LocationState.h"
|
#include "LocationState.h"
|
||||||
#include "ISaveable.h"
|
#include "LocationGameState.h"
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <chrono>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include "Character.h"
|
||||||
|
|
||||||
namespace FRG
|
namespace FRG
|
||||||
{
|
{
|
||||||
|
class IGameServer;
|
||||||
|
|
||||||
struct PointLight
|
struct PointLight
|
||||||
{
|
{
|
||||||
@@ -35,7 +38,7 @@ namespace FRG
|
|||||||
// Rectangular limits — keep light confined to a room-sized AABB centred on the light
|
// 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 limitX = 99999.f; // shader fragment limit: half-width in world X
|
||||||
float limitY = 99999.f; // shader fragment limit: half-width in world Z
|
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
|
float autoLightLimitY = -1.0f; // activation rect half-width Z
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,16 +49,17 @@ namespace FRG
|
|||||||
float radius = 1.5f;
|
float radius = 1.5f;
|
||||||
float hysteresis = 0.3f; // exit fires at radius + hysteresis to prevent flickering
|
float hysteresis = 0.3f; // exit fires at radius + hysteresis to prevent flickering
|
||||||
bool enabled = true;
|
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
|
struct LocationSetup
|
||||||
{
|
{
|
||||||
|
std::shared_ptr<const LocationDefinition> definition;
|
||||||
std::string gameObjectsJsonPath;
|
std::string gameObjectsJsonPath;
|
||||||
std::string interactiveObjectsJsonPath;
|
std::string interactiveObjectsJsonPath;
|
||||||
std::string npcsJsonPath;
|
std::string npcsJsonPath;
|
||||||
std::string dialoguesJsonPath;
|
std::string dialoguesJsonPath;
|
||||||
std::vector<std::string> navigationJsonPaths;
|
//std::vector<std::string> navigationJsonPaths;
|
||||||
std::string scriptPath;
|
std::string scriptPath;
|
||||||
std::string teleportsJsonPath;
|
std::string teleportsJsonPath;
|
||||||
std::string triggerZonesJsonPath;
|
std::string triggerZonesJsonPath;
|
||||||
@@ -64,17 +68,28 @@ namespace FRG
|
|||||||
// Player shared by all locations of a campaign (see Location::createPlayer);
|
// Player shared by all locations of a campaign (see Location::createPlayer);
|
||||||
// setup() creates a private one when this is null.
|
// setup() creates a private one when this is null.
|
||||||
std::shared_ptr<Character> player;
|
std::shared_ptr<Character> player;
|
||||||
|
LocationGameState* gameState = nullptr;
|
||||||
|
std::shared_ptr<LocationLocalState> localState;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Location : public ISaveable
|
class Location
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Location(Renderer& iRenderer, Inventory& iInventory);
|
Location(Renderer& iRenderer, IGameServer& server,
|
||||||
|
std::string locationId);
|
||||||
// Detaches the (shared, longer-lived) player from this location.
|
// Detaches the (shared, longer-lived) player from this location.
|
||||||
~Location();
|
~Location();
|
||||||
|
const std::string& locationId() const { return locationId_; }
|
||||||
// ---- All serialisable runtime state ----
|
std::shared_ptr<const LocationNetworkState> stateSnapshot() const;
|
||||||
LocationState state;
|
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;
|
std::unordered_map<std::string, LoadedGameObject> gameObjects;
|
||||||
|
|
||||||
@@ -88,10 +103,8 @@ namespace FRG
|
|||||||
std::vector<std::unique_ptr<Character>> npcs;
|
std::vector<std::unique_ptr<Character>> npcs;
|
||||||
|
|
||||||
// Builds the player character (models, animations, weapon, textures).
|
// Builds the player character (models, animations, weapon, textures).
|
||||||
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer);
|
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer,
|
||||||
|
IGameServer& server, const std::string& playerId);
|
||||||
std::vector<PathFinder> navigationMaps;
|
|
||||||
PathFinder* navigation = nullptr;
|
|
||||||
|
|
||||||
std::shared_ptr<ShadowMap> shadowMap;
|
std::shared_ptr<ShadowMap> shadowMap;
|
||||||
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
|
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
|
||||||
@@ -114,10 +127,6 @@ namespace FRG
|
|||||||
|
|
||||||
std::function<void()> onPlayerTaxiRequired;
|
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
|
// Set by Game after setup(). Lua and onDeathAnimComplete call this to
|
||||||
// request the transition without coupling Location to Game directly.
|
// request the transition without coupling Location to Game directly.
|
||||||
std::function<bool()> requestDarklandsTransition;
|
std::function<bool()> requestDarklandsTransition;
|
||||||
@@ -140,7 +149,7 @@ namespace FRG
|
|||||||
EditorMode editorMode = EditorMode::None;
|
EditorMode editorMode = EditorMode::None;
|
||||||
LocationEditor editor;
|
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)
|
// 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.
|
// has crossed the tap-vs-drag threshold and is now rotating the camera.
|
||||||
@@ -161,9 +170,33 @@ namespace FRG
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
void setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer);
|
void setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer);
|
||||||
void setupNavigation(const std::vector<std::string>& paths);
|
//void setupNavigation(const std::vector<std::string>& paths);
|
||||||
bool switchNavigation(int index);
|
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);
|
InteractiveObject* raycastInteractiveObjects(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir);
|
||||||
Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f);
|
Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f);
|
||||||
|
|
||||||
@@ -177,8 +210,8 @@ namespace FRG
|
|||||||
|
|
||||||
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
|
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
|
||||||
|
|
||||||
void update(int64_t deltaMs);
|
void update(int64_t deltaMs, const LocationSimulationEvents& events);
|
||||||
void updateNpcCar(int64_t deltaMs);
|
void updatePresentation(int64_t deltaMs);
|
||||||
// Purely visual feedback for click-triggered player routes. Disabling it
|
// Purely visual feedback for click-triggered player routes. Disabling it
|
||||||
// also cancels an animation that is currently playing.
|
// also cancels an animation that is currently playing.
|
||||||
void setPathVisualizationEnabled(bool enabled);
|
void setPathVisualizationEnabled(bool enabled);
|
||||||
@@ -194,26 +227,26 @@ namespace FRG
|
|||||||
void setDialogueFlag(const std::string& flag, int value);
|
void setDialogueFlag(const std::string& flag, int value);
|
||||||
int getDialogueFlag(const std::string& flag) const;
|
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:
|
protected:
|
||||||
friend class LocationEditor;
|
friend class LocationEditor;
|
||||||
|
friend class Game;
|
||||||
|
friend class ScriptEngine;
|
||||||
|
friend class LocalGameServerHost;
|
||||||
|
friend class RemoteGameServerHost;
|
||||||
Renderer& renderer;
|
Renderer& renderer;
|
||||||
Inventory& inventory;
|
|
||||||
AudioPlayerAsync* audioPlayer;
|
AudioPlayerAsync* audioPlayer;
|
||||||
|
|
||||||
private:
|
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 ----
|
// ---- Pointer-to-index helpers ----
|
||||||
InteractiveObject* getTargetInteractiveObject();
|
TeleportZone* getTargetTeleportZone(const LocationNetworkState& state);
|
||||||
TeleportZone* getTargetTeleportZone();
|
|
||||||
int findInteractiveObjectIndex(const InteractiveObject* obj) const;
|
|
||||||
int findTeleportZoneIndex(const TeleportZone* tz) const;
|
|
||||||
|
|
||||||
enum class InstancedTexture { None, Color, Darklands };
|
enum class InstancedTexture { None, Color, Darklands };
|
||||||
|
|
||||||
@@ -223,26 +256,31 @@ namespace FRG
|
|||||||
void drawInstancedStatics(const std::string& instancedShaderName, InstancedTexture textureKind,
|
void drawInstancedStatics(const std::string& instancedShaderName, InstancedTexture textureKind,
|
||||||
const std::function<void()>& setUniforms = {});
|
const std::function<void()>& setUniforms = {});
|
||||||
|
|
||||||
void resolveCharacterCollisions();
|
|
||||||
void updateDynamicReplans(int64_t deltaMs);
|
|
||||||
void loadTeleportZones(const std::string& jsonPath, const char* zipFile);
|
void loadTeleportZones(const std::string& jsonPath, const char* zipFile);
|
||||||
void loadTriggerZones(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 loadPointLights(const std::string& jsonPath, const char* zipFile);
|
||||||
void updateTriggerZones(const Eigen::Vector3f& playerPos);
|
void updateTriggerZones(const Eigen::Vector3f& playerPos);
|
||||||
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
|
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
|
||||||
int findNpcIndex(const Character* npc) const;
|
void captureAuxiliaryState(LocationGameState& out) const;
|
||||||
void fireBumpCallbacks(Character* mover, Character* standing);
|
void applyAuxiliaryState(const LocationGameState& in);
|
||||||
|
|
||||||
PathVisualization pathVisualization;
|
PathVisualization pathVisualization;
|
||||||
|
|
||||||
std::unordered_map<Character*, Eigen::Vector3f> lastCharacterPositions;
|
struct NpcCarPresentationSample {
|
||||||
std::unordered_map<Character*, int64_t> replanCooldownRemainingMs;
|
Eigen::Vector3f position = Eigen::Vector3f::Zero();
|
||||||
std::unordered_map<Character*, int> stuckFrameCounts;
|
float rotation = 0.f;
|
||||||
std::unordered_map<Character*, float> stuckPauseRemainingS;
|
float wheelAngle = 0.f;
|
||||||
|
float steeringAngle = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
NpcCarPresentationSample previousNpcCarPresentation_;
|
||||||
|
NpcCarPresentationSample currentNpcCarPresentation_;
|
||||||
|
std::chrono::steady_clock::time_point lastNpcCarPresentationUpdate_;
|
||||||
|
bool hasNpcCarPresentationSample_ = false;
|
||||||
|
|
||||||
|
void captureNpcCarPresentation(const NpcCar& car);
|
||||||
|
NpcCarPresentationSample interpolatedNpcCarPresentation(const NpcCar& car) const;
|
||||||
|
|
||||||
static constexpr float NPC_BUMP_CALLBACK_COOLDOWN = 5.0f;
|
|
||||||
std::unordered_map<int, float> npcBumpedByPlayerCooldown;
|
|
||||||
std::unordered_map<int, float> npcBumpsPlayerCooldown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace FRG
|
} // namespace FRG
|
||||||
+45
-15
@@ -1,6 +1,7 @@
|
|||||||
#include "LocationEditor.h"
|
#include "LocationEditor.h"
|
||||||
#include "Location.h"
|
#include "Location.h"
|
||||||
#include "Character.h"
|
#include "Character.h"
|
||||||
|
#include "game_server/IGameServer.h"
|
||||||
#include "utils/Utils.h"
|
#include "utils/Utils.h"
|
||||||
#include "render/OpenGlExtensions.h"
|
#include "render/OpenGlExtensions.h"
|
||||||
#include "TextModel.h"
|
#include "TextModel.h"
|
||||||
@@ -24,10 +25,14 @@ namespace FRG
|
|||||||
void LocationEditor::buildNavMeshes()
|
void LocationEditor::buildNavMeshes()
|
||||||
{
|
{
|
||||||
navigationEditorNavMeshes.clear();
|
navigationEditorNavMeshes.clear();
|
||||||
if (!loc.navigation) return;
|
auto state = loc.stateSnapshot();
|
||||||
const float y = loc.navigation->getFloorY() + 0.02f;
|
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);
|
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;
|
if (obs.polygon.size() < 3) continue;
|
||||||
VertexRenderStruct mesh;
|
VertexRenderStruct mesh;
|
||||||
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
|
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
|
||||||
@@ -51,7 +56,11 @@ namespace FRG
|
|||||||
{
|
{
|
||||||
VertexDataStruct data;
|
VertexDataStruct data;
|
||||||
const Eigen::Vector3f yellow(1.0f, 1.0f, 0.0f);
|
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;
|
const float s = 0.2f;
|
||||||
|
|
||||||
for (const auto& pt : navigationEditorPoints) {
|
for (const auto& pt : navigationEditorPoints) {
|
||||||
@@ -68,12 +77,19 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigationEditorPointsMesh.data = std::move(data);
|
navigationEditorPointsMesh.data = std::move(data);
|
||||||
navigationEditorPointsMesh.RefreshVBO();
|
if (navigationEditorPointsMesh.data.PositionData.size() > 0)
|
||||||
|
{
|
||||||
|
navigationEditorPointsMesh.RefreshVBO();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void LocationEditor::drawPoints()
|
void LocationEditor::drawPoints()
|
||||||
{
|
{
|
||||||
if (navigationEditorPoints.empty()) return;
|
if (navigationEditorPoints.empty()) return;
|
||||||
|
if (navigationEditorPointsMesh.data.PositionData.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
loc.renderer.shaderManager.PushShader("defaultColor");
|
loc.renderer.shaderManager.PushShader("defaultColor");
|
||||||
loc.renderer.SetMatrix();
|
loc.renderer.SetMatrix();
|
||||||
loc.renderer.DrawVertexRenderStruct(navigationEditorPointsMesh);
|
loc.renderer.DrawVertexRenderStruct(navigationEditorPointsMesh);
|
||||||
@@ -120,12 +136,17 @@ namespace FRG
|
|||||||
poly.polygon.emplace_back(pt.x(), pt.z());
|
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 "
|
logger() << "[NAV_EDITOR] Added obstacle '" << poly.name << "' with "
|
||||||
<< poly.polygon.size() << " vertices\n";
|
<< 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);
|
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
|
||||||
VertexRenderStruct mesh;
|
VertexRenderStruct mesh;
|
||||||
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
|
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
|
||||||
@@ -147,14 +168,21 @@ namespace FRG
|
|||||||
if (!std::filesystem::exists(baseName + ".json")) break;
|
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";
|
logger() << "[NAV_EDITOR] Saved config to: " << baseName << ".json\n";
|
||||||
else
|
else
|
||||||
std::cerr << "[NAV_EDITOR] Failed to save config to: " << baseName << ".json\n";
|
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";
|
logger() << "[NAV_EDITOR] Saved grid to: " << baseName << ".txt\n";
|
||||||
else
|
else
|
||||||
std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n";
|
std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n";
|
||||||
@@ -162,8 +190,8 @@ namespace FRG
|
|||||||
|
|
||||||
void LocationEditor::reload()
|
void LocationEditor::reload()
|
||||||
{
|
{
|
||||||
loc.setupNavigation(loc.navigationMapPaths);
|
//loc.setupNavigation(loc.navigationMapPaths);
|
||||||
logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
|
//logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
static VertexDataStruct CreateBoundsBoxMesh(const Eigen::Vector3f corners[8], const Eigen::Vector3f& color)
|
static VertexDataStruct CreateBoundsBoxMesh(const Eigen::Vector3f corners[8], const Eigen::Vector3f& color)
|
||||||
@@ -454,9 +482,11 @@ namespace FRG
|
|||||||
data.rotationX = 0.0f;
|
data.rotationX = 0.0f;
|
||||||
data.rotationY = rotDist(rng);
|
data.rotationY = rotDist(rng);
|
||||||
data.rotationZ = 0.0f;
|
data.rotationZ = 0.0f;
|
||||||
data.positionX = loc.player->state.position.x();
|
auto playerState = loc.player ? loc.player->stateSnapshot() : nullptr;
|
||||||
data.positionY = loc.player->state.position.y();//-0.1;
|
if (!playerState) return;
|
||||||
data.positionZ = loc.player->state.position.z();
|
data.positionX = playerState->position.x();
|
||||||
|
data.positionY = playerState->position.y();//-0.1;
|
||||||
|
data.positionZ = playerState->position.z();
|
||||||
data.scale = scaleDist(rng);
|
data.scale = scaleDist(rng);
|
||||||
|
|
||||||
GameObjectData data2;
|
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 "LocationState.h"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
void LocationState::save(nlohmann::json& out) const
|
std::shared_ptr<LocationLocalState> ClientLocationState::getOrCreate(
|
||||||
|
const std::string& locationId)
|
||||||
{
|
{
|
||||||
out["cameraAzimuth"] = cameraAzimuth;
|
auto& state = locations[locationId];
|
||||||
out["cameraInclination"] = cameraInclination;
|
if (!state) state = std::make_shared<LocationLocalState>();
|
||||||
|
return state;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void LocationState::load(const nlohmann::json& in)
|
void ClientLocationState::clear()
|
||||||
{
|
{
|
||||||
//logger() << "LocationState::load step 1" << std::endl;
|
locations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
cameraAzimuth = in.value("cameraAzimuth", cameraAzimuth);
|
void LocationLocalState::save(nlohmann::json& out) const
|
||||||
cameraInclination = in.value("cameraInclination", cameraInclination);
|
{
|
||||||
|
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);
|
activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex);
|
||||||
|
|
||||||
targetInteractiveObjectIndex = in.value("targetInteractiveObjectIndex", -1);
|
targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string());
|
||||||
targetInteractNpcIndex = in.value("targetInteractNpcIndex", -1);
|
targetInteractNpcId = in.value("targetInteractNpcId", std::string());
|
||||||
targetTeleportZoneIndex = in.value("targetTeleportZoneIndex", -1);
|
targetTeleportZoneId = in.value("targetTeleportZoneId", std::string());
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
|
||||||
|
const auto& taxi = in.at("taxi");
|
||||||
|
loadCar(taxiCar, taxi.at("car"));
|
||||||
|
taxiArrivedCallbackCalled =
|
||||||
|
taxi.at("arrivedCallbackCalled").get<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace FRG
|
} // namespace FRG
|
||||||
+46
-17
@@ -2,34 +2,63 @@
|
|||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
#include "NpcCar.h"
|
#include "NpcCar.h"
|
||||||
|
#include "navigation/PathFinder.h"
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
struct LocationState : public ISaveable {
|
inline constexpr int kSinglePlayerPersistenceVersion = 1;
|
||||||
// ---- Camera ----
|
|
||||||
float cameraAzimuth = -2.35f;
|
struct LocationLocalState : public ISaveable {
|
||||||
float cameraInclination = 1.1036f;
|
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 ----
|
// ---- Navigation ----
|
||||||
int activeNavigationIndex = 0;
|
int activeNavigationIndex = 0;
|
||||||
|
|
||||||
// ---- Walk-to targets (-1 = none) ----
|
// ---- Walk-to targets (empty = none) ----
|
||||||
// Mirrors CharacterState's kNoTarget convention; raw pointers are not serialisable.
|
std::string targetInteractiveObjectId;
|
||||||
int targetInteractiveObjectIndex = -1;
|
std::string targetInteractNpcId;
|
||||||
int targetInteractNpcIndex = -1;
|
std::string targetTeleportZoneId;
|
||||||
int targetTeleportZoneIndex = -1;
|
|
||||||
|
|
||||||
// ---- World-mode flags (synced from Game after each transition) ----
|
|
||||||
bool isDarklands = false;
|
|
||||||
bool isNight = false;
|
|
||||||
bool isDawn = false;
|
|
||||||
|
|
||||||
|
|
||||||
NpcCar taxiCarInitialState;
|
NpcCar taxiCarInitialState;
|
||||||
NpcCar taxiCar;
|
NpcCar taxiCar;
|
||||||
bool taxiArrivedCallbackCalled = false;
|
bool taxiArrivedCallbackCalled = false;
|
||||||
Vector3f taxiCarTriggerPosition = Vector3f(0,0,0);
|
Eigen::Vector3f taxiCarTriggerPosition = Eigen::Vector3f(0,0,0);
|
||||||
std::vector<Vector3f> taxiCarWaypoints;
|
std::vector<Eigen::Vector3f> taxiCarWaypoints;
|
||||||
|
|
||||||
// (trigger/teleport zone runtime flags are serialised by id directly in Location::save/load)
|
// (trigger/teleport zone runtime flags are serialised by id directly in Location::save/load)
|
||||||
|
|
||||||
|
|||||||
+131
-114
@@ -201,9 +201,9 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
MenuManager::MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer) :
|
MenuManager::MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer) :
|
||||||
renderer(iRenderer),
|
renderer(iRenderer),
|
||||||
gameState_(gameState),
|
server_(server),
|
||||||
audioPlayer_(audioPlayer)
|
audioPlayer_(audioPlayer)
|
||||||
{
|
{
|
||||||
//Localization
|
//Localization
|
||||||
@@ -338,9 +338,7 @@ namespace FRG {
|
|||||||
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
|
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::setup(Inventory& inv, const std::string& zipFile) {
|
void MenuManager::setup(const std::string& zipFile) {
|
||||||
inventory = &inv;
|
|
||||||
|
|
||||||
loadUiRoots(zipFile);
|
loadUiRoots(zipFile);
|
||||||
|
|
||||||
texObjectiveCompleted_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_completed.png", zipFile, true);
|
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);
|
languageLoadingRu = renderer.textureManager.LoadFromPng("resources/loading_land003_ru.png", zipFile);
|
||||||
languageLoadingEn = renderer.textureManager.LoadFromPng("resources/loading_land003_en.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/";
|
const std::string imgDir = "resources/w/ui/img/phone/";
|
||||||
texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true);
|
texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true);
|
||||||
texBubbleInLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_top.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 {
|
std::shared_ptr<UiNode> MenuManager::gameplayHudRoot() const {
|
||||||
const std::string& loc = gameState_.currentLocationName;
|
const auto snapshot = server_.snapshot();
|
||||||
if (loc == "uni_exterior") return gameState_.isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
|
const std::string& loc = snapshot->currentLocationId;
|
||||||
if (loc == "uni_interior") return gameState_.isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
|
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
|
return hudDormRoot; // dorm, or a new game before the location is set
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,21 +420,22 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::refreshHudVisibility() {
|
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 auto snapshot = server_.snapshot();
|
||||||
const bool tutorial = gameState_.isTutorialActive();
|
const TutorialStep step = snapshot->tutorialStep;
|
||||||
const bool hasPhone = inventory->hasItem("phone");
|
const bool tutorial = snapshot->isTutorialActive();
|
||||||
const bool hasJournal = inventory->hasItem("journal");
|
const bool hasPhone = std::find(itemIds.begin(), itemIds.end(), "phone") != itemIds.end();
|
||||||
const bool hasNote = inventory->hasItem("note_spell");
|
const bool hasJournal = std::find(itemIds.begin(), itemIds.end(), "journal") != itemIds.end();
|
||||||
const bool dark = gameState_.isDarklands;
|
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) {
|
auto show = [this](std::initializer_list<const char*> names, bool visible) {
|
||||||
for (const char* name : names) uiManager.setNodeVisible(name, visible);
|
for (const char* name : names) uiManager.setNodeVisible(name, visible);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- HUD buttons: present while the matching item is in the inventory ---
|
// --- 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({ "phoneButton" }, hasPhone && !dark);
|
||||||
show({ "journalButton" }, hasJournal);
|
show({ "journalButton" }, hasJournal);
|
||||||
show({ "darklandsButton" }, hasNote && !dark);
|
show({ "darklandsButton" }, hasNote && !dark);
|
||||||
@@ -466,9 +453,9 @@ namespace FRG {
|
|||||||
show({ "hint4" }, step == TutorialStep::Zoom);
|
show({ "hint4" }, step == TutorialStep::Zoom);
|
||||||
show({ "hint5" }, step == TutorialStep::PickUpItem);
|
show({ "hint5" }, step == TutorialStep::PickUpItem);
|
||||||
// ...then the hints for the picked-up items, which can be done in any order.
|
// ...then the hints for the picked-up items, which can be done in any order.
|
||||||
const bool chatHint = tutorial && !gameState_.tutorialPhoneChatScreenOpened;
|
const bool chatHint = tutorial && !snapshot->tutorialPhoneChatScreenOpened;
|
||||||
const bool journalHint = tutorial && !gameState_.tutorialJournalScreenOpened;
|
const bool journalHint = tutorial && !snapshot->tutorialJournalScreenOpened;
|
||||||
const bool taxiHint = tutorial && gameState_.tutorialNeedOpenTaxiScreen;
|
const bool taxiHint = tutorial && snapshot->tutorialNeedOpenTaxiScreen;
|
||||||
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
|
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
|
||||||
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
|
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
|
||||||
show({ "hint7", "hint7arrow" }, taxiHint);
|
show({ "hint7", "hint7arrow" }, taxiHint);
|
||||||
@@ -485,7 +472,7 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::showMainMenu() {
|
void MenuManager::showMainMenu() {
|
||||||
gameState_.currentLocationName.clear();
|
server_.submit(SetCurrentLocationCommand{""});
|
||||||
|
|
||||||
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
|
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
|
||||||
lastMusicTrack = "music/main menu final.ogg";
|
lastMusicTrack = "music/main menu final.ogg";
|
||||||
@@ -546,7 +533,6 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::showMenuAfterGameCompleted() {
|
void MenuManager::showMenuAfterGameCompleted() {
|
||||||
//gameState_.currentLocationName.clear();
|
|
||||||
|
|
||||||
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
|
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
|
||||||
lastMusicTrack = "music/main menu final.ogg";
|
lastMusicTrack = "music/main menu final.ogg";
|
||||||
@@ -1060,8 +1046,8 @@ namespace FRG {
|
|||||||
|
|
||||||
steps.push_back([this]() {
|
steps.push_back([this]() {
|
||||||
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
|
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
|
||||||
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState_.campaignId))
|
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(server_.snapshot()->campaignId))
|
||||||
gameState_.questJournal.reloadDefinitionsPreservingState(campaign->questsPath, CONST_ZIP_FILE);
|
server_.submit(ReloadQuestDefinitionsCommand{campaign->questsPath, CONST_ZIP_FILE});
|
||||||
});
|
});
|
||||||
|
|
||||||
steps.push_back([this]() {
|
steps.push_back([this]() {
|
||||||
@@ -1197,7 +1183,7 @@ namespace FRG {
|
|||||||
|
|
||||||
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
|
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
|
||||||
|
|
||||||
const auto& items = inventory->getItems();
|
const auto items = inventoryItems();
|
||||||
/*logger() << "Item count: " << items.size() << std::endl;
|
/*logger() << "Item count: " << items.size() << std::endl;
|
||||||
for (int x = 0; x < items.size(); x++)
|
for (int x = 0; x < items.size(); x++)
|
||||||
{
|
{
|
||||||
@@ -1236,7 +1222,7 @@ namespace FRG {
|
|||||||
|
|
||||||
void MenuManager::selectInventoryItem(int index) {
|
void MenuManager::selectInventoryItem(int index) {
|
||||||
logger() << "MenuManager::selectInventoryItem: " << index << std::endl;
|
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 (index < 0 || index >= static_cast<int>(items.size())) return;
|
||||||
|
|
||||||
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
|
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
|
||||||
@@ -1288,7 +1274,10 @@ namespace FRG {
|
|||||||
|
|
||||||
void MenuManager::openQuestJournal() {
|
void MenuManager::openQuestJournal() {
|
||||||
uiState_ = GameUiState::QuestJournal;
|
uiState_ = GameUiState::QuestJournal;
|
||||||
gameState_.tutorialJournalScreenOpened = true;
|
auto tutorial = server_.snapshot();
|
||||||
|
server_.submit(SetTutorialFlagsCommand{
|
||||||
|
tutorial->tutorialPhoneChatScreenOpened, true,
|
||||||
|
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
|
||||||
journalMenuStackDepth_ = 1;
|
journalMenuStackDepth_ = 1;
|
||||||
|
|
||||||
const bool portrait = isPortraitMode();
|
const bool portrait = isPortraitMode();
|
||||||
@@ -1380,9 +1369,10 @@ namespace FRG {
|
|||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
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("phoneTimeDay", false);
|
||||||
uiManager.setNodeVisible("phoneTimeNight", false);
|
uiManager.setNodeVisible("phoneTimeNight", false);
|
||||||
@@ -1418,51 +1408,67 @@ namespace FRG {
|
|||||||
});
|
});
|
||||||
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
|
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
|
||||||
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
|
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
|
||||||
gameState_.chatUnread[0] = false;
|
setChatUnread(0, false);
|
||||||
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
|
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
|
||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
||||||
});
|
});
|
||||||
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
|
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
|
||||||
gameState_.chatUnread[1] = false;
|
setChatUnread(1, false);
|
||||||
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
|
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
|
||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
||||||
});
|
});
|
||||||
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
|
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
|
||||||
gameState_.chatUnread[2] = false;
|
setChatUnread(2, false);
|
||||||
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
|
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
|
||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::refreshChatUnreadIndicators() {
|
void MenuManager::refreshChatUnreadIndicators() {
|
||||||
|
const auto state = server_.snapshot();
|
||||||
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
|
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
|
||||||
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
|
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
|
||||||
for (int i = 0; i < 3; ++i) {
|
for (int i = 0; i < 3; ++i) {
|
||||||
uiManager.setNodeVisible(kUnreadNodes[i], gameState_.chatUnread[i]);
|
uiManager.setNodeVisible(kUnreadNodes[i], state->chatUnread[i]);
|
||||||
if (!gameState_.chatPreviewMsg[i].empty())
|
if (!state->chatPreview[i].empty())
|
||||||
uiManager.setText(kMsgNodes[i], gameState_.chatPreviewMsg[i]);
|
uiManager.setText(kMsgNodes[i], state->chatPreview[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
|
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
|
||||||
if (chatIndex < 0 || chatIndex > 2) return;
|
if (chatIndex < 0 || chatIndex > 2) return;
|
||||||
gameState_.chatUnread[chatIndex] = unread;
|
const auto state = server_.snapshot();
|
||||||
|
std::string preview = state->chatPreview[chatIndex];
|
||||||
if (!previewMsg.empty()) {
|
if (!previewMsg.empty()) {
|
||||||
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(previewMsg);
|
preview = trimChatPreview(previewMsg);
|
||||||
} else if (!unread && !gameState_.chatHistory[chatIndex].empty()) {
|
} else if (!unread && !state->chatHistory[chatIndex].empty()) {
|
||||||
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(gameState_.chatHistory[chatIndex].back().text);
|
preview = trimChatPreview(state->chatHistory[chatIndex].back().text);
|
||||||
}
|
}
|
||||||
|
server_.submit(SetChatCommand{chatIndex, unread, std::move(preview)});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::spendMoney(int amount) {
|
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() {
|
void MenuManager::openPhoneBank() {
|
||||||
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
|
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
|
||||||
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneBankRoot : phoneBankRoot);
|
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&) {
|
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
|
||||||
closePhoneEntirely();
|
closePhoneEntirely();
|
||||||
@@ -1492,13 +1498,15 @@ namespace FRG {
|
|||||||
});
|
});
|
||||||
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
|
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
|
||||||
closePhoneEntirely();
|
closePhoneEntirely();
|
||||||
if (gameState_.isNight)
|
const auto state = server_.snapshot();
|
||||||
|
if (state->isNight)
|
||||||
{
|
{
|
||||||
startDialogueFunc("dialog_video002");
|
startDialogueFunc("dialog_video002");
|
||||||
}
|
}
|
||||||
else
|
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)
|
if (day == 0)
|
||||||
{
|
{
|
||||||
startDialogueFunc("dialog_video003");
|
startDialogueFunc("dialog_video003");
|
||||||
@@ -1513,10 +1521,11 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::openPhoneTaxi() {
|
void MenuManager::openPhoneTaxi() {
|
||||||
if (gameState_.currentLocationName == "uni_interior") {
|
const std::string location = server_.snapshot()->currentLocationId;
|
||||||
|
if (location == "uni_interior") {
|
||||||
closePhoneEntirely();
|
closePhoneEntirely();
|
||||||
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
|
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
|
||||||
} else if (gameState_.currentLocationName == "uni_exterior") {
|
} else if (location == "uni_exterior") {
|
||||||
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
|
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
|
||||||
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
|
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
|
||||||
} else {
|
} else {
|
||||||
@@ -1539,17 +1548,18 @@ namespace FRG {
|
|||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
||||||
});
|
});
|
||||||
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
|
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
|
||||||
gameState_.tutorialNeedOpenTaxiScreen = false;
|
auto state = server_.snapshot();
|
||||||
gameState_.tutorialTaxiHintDone = true;
|
server_.submit(SetTutorialFlagsCommand{
|
||||||
|
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
|
||||||
|
false, true});
|
||||||
|
|
||||||
if (gameState_.taxiIsCalled == false)
|
if (!state->taxiIsCalled)
|
||||||
{
|
{
|
||||||
gameState_.taxiIsCalled = true;
|
server_.submit(SetTaxiCalledCommand{true});
|
||||||
if (callTaxiFunc)
|
server_.submit(StartTaxiCommand{state->currentLocationId});
|
||||||
{
|
server_.submit(SetTriggerZoneEnabledCommand{
|
||||||
callTaxiFunc();
|
state->currentLocationId, "taxi_zone001", false});
|
||||||
}
|
server_.submit(SpendMoneyCommand{500});
|
||||||
gameState_.money -= 500;
|
|
||||||
closePhoneEntirely();
|
closePhoneEntirely();
|
||||||
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
|
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
|
||||||
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
|
||||||
@@ -1569,7 +1579,10 @@ namespace FRG {
|
|||||||
activeChatIndex_ = chatIndex;
|
activeChatIndex_ = chatIndex;
|
||||||
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
|
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
|
||||||
phoneChatVisibleBubbles_.clear();
|
phoneChatVisibleBubbles_.clear();
|
||||||
gameState_.tutorialPhoneChatScreenOpened = true;
|
auto tutorial = server_.snapshot();
|
||||||
|
server_.submit(SetTutorialFlagsCommand{
|
||||||
|
true, tutorial->tutorialJournalScreenOpened,
|
||||||
|
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
|
||||||
uiManager.pushMenuFromSavedRoot(chatRoot);
|
uiManager.pushMenuFromSavedRoot(chatRoot);
|
||||||
|
|
||||||
// Build a dedicated chatUiManager root sized to the FBO chat area
|
// Build a dedicated chatUiManager root sized to the FBO chat area
|
||||||
@@ -1616,11 +1629,7 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::returnToPhoneChatList() {
|
void MenuManager::returnToPhoneChatList() {
|
||||||
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
|
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
|
||||||
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
|
|
||||||
gameState_.chatPreviewMsg[activeChatIndex_] =
|
|
||||||
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
|
|
||||||
}
|
|
||||||
activeChatIndex_ = -1;
|
activeChatIndex_ = -1;
|
||||||
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
|
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
|
||||||
phoneChatVisibleBubbles_.clear();
|
phoneChatVisibleBubbles_.clear();
|
||||||
@@ -1634,11 +1643,7 @@ namespace FRG {
|
|||||||
|
|
||||||
|
|
||||||
void MenuManager::closePhoneEntirely() {
|
void MenuManager::closePhoneEntirely() {
|
||||||
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
|
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
|
||||||
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
|
|
||||||
gameState_.chatPreviewMsg[activeChatIndex_] =
|
|
||||||
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
|
|
||||||
}
|
|
||||||
activeChatIndex_ = -1;
|
activeChatIndex_ = -1;
|
||||||
currentPhoneSubScreen_ = PhoneSubScreen::None;
|
currentPhoneSubScreen_ = PhoneSubScreen::None;
|
||||||
uiState_ = GameUiState::Gameplay;
|
uiState_ = GameUiState::Gameplay;
|
||||||
@@ -1699,8 +1704,11 @@ namespace FRG {
|
|||||||
void MenuManager::tutorialShowTaxiHint()
|
void MenuManager::tutorialShowTaxiHint()
|
||||||
{
|
{
|
||||||
logger() << "tutorialShowTaxiHint" << std::endl;
|
logger() << "tutorialShowTaxiHint" << std::endl;
|
||||||
if (!gameState_.isTutorialActive() || gameState_.tutorialTaxiHintDone) return;
|
auto state = server_.snapshot();
|
||||||
gameState_.tutorialNeedOpenTaxiScreen = true; // cleared when the taxi is ordered
|
if (!state->isTutorialActive() || state->tutorialTaxiHintDone) return;
|
||||||
|
server_.submit(SetTutorialFlagsCommand{
|
||||||
|
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
|
||||||
|
true, state->tutorialTaxiHintDone});
|
||||||
refreshHudVisibility();
|
refreshHudVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1723,16 +1731,17 @@ namespace FRG {
|
|||||||
|
|
||||||
void MenuManager::onLocationChanged(const std::string& locationName) {
|
void MenuManager::onLocationChanged(const std::string& locationName) {
|
||||||
if (uiState_ != GameUiState::Gameplay) return;
|
if (uiState_ != GameUiState::Gameplay) return;
|
||||||
gameState_.currentLocationName = locationName;
|
server_.submit(SetCurrentLocationCommand{locationName});
|
||||||
|
const auto state = server_.snapshot();
|
||||||
showGameplayHud();
|
showGameplayHud();
|
||||||
|
|
||||||
if (locationName == "uni_exterior" || locationName == "uni_interior") {
|
if (locationName == "uni_exterior" || locationName == "uni_interior") {
|
||||||
if (gameState_.isDarklands)
|
if (state->isDarklands)
|
||||||
{
|
{
|
||||||
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
|
||||||
lastMusicTrack = "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");
|
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
|
||||||
lastMusicTrack = "music/bishkek night.ogg";
|
lastMusicTrack = "music/bishkek night.ogg";
|
||||||
@@ -1743,7 +1752,7 @@ namespace FRG {
|
|||||||
lastMusicTrack = "music/bishkek univer day.ogg";
|
lastMusicTrack = "music/bishkek univer day.ogg";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (gameState_.isNight)
|
if (state->isNight)
|
||||||
{
|
{
|
||||||
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
|
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
|
||||||
lastMusicTrack = "music/bishkek night.ogg";
|
lastMusicTrack = "music/bishkek night.ogg";
|
||||||
@@ -1759,13 +1768,16 @@ namespace FRG {
|
|||||||
void MenuManager::onItemPickedUp(const std::string& itemId) {
|
void MenuManager::onItemPickedUp(const std::string& itemId) {
|
||||||
// The new item's HUD button appears through refreshHudVisibility().
|
// The new item's HUD button appears through refreshHudVisibility().
|
||||||
if (itemId == "phone" || itemId == "journal")
|
if (itemId == "phone" || itemId == "journal")
|
||||||
gameState_.completeTutorialStep(TutorialStep::PickUpItem);
|
server_.submit(CompleteTutorialStepCommand{TutorialStep::PickUpItem});
|
||||||
refreshHudVisibility();
|
refreshHudVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::refreshQuestJournalUi() {
|
void MenuManager::refreshQuestJournalUi() {
|
||||||
visibleQuestIds.clear();
|
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) {
|
std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) {
|
||||||
const int pa = questStatusPriority(a->status);
|
const int pa = questStatusPriority(a->status);
|
||||||
@@ -1840,7 +1852,10 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedQuestIndex = index;
|
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) {
|
if (!quest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1888,7 +1903,7 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::openPortraitInventoryItem(int index) {
|
void MenuManager::openPortraitInventoryItem(int index) {
|
||||||
const auto& items = inventory->getItems();
|
const auto items = inventoryItems();
|
||||||
inventorySelectedIndex_ = index;
|
inventorySelectedIndex_ = index;
|
||||||
inventoryMenuStackDepth_ = 2;
|
inventoryMenuStackDepth_ = 2;
|
||||||
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
|
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
|
||||||
@@ -1943,7 +1958,10 @@ namespace FRG {
|
|||||||
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
|
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;
|
if (!quest) return;
|
||||||
const auto& def = quest->definition;
|
const auto& def = quest->definition;
|
||||||
|
|
||||||
@@ -2070,7 +2088,8 @@ namespace FRG {
|
|||||||
phoneChatVisibleBubbles_.clear();
|
phoneChatVisibleBubbles_.clear();
|
||||||
|
|
||||||
if (chatIndex < 0 || chatIndex > 2) return;
|
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 bool inc = msg.incoming;
|
||||||
const std::string nodeName = chatUiManager.addChatBubble(
|
const std::string nodeName = chatUiManager.addChatBubble(
|
||||||
"chatMessagesContainer", msg.text, inc,
|
"chatMessagesContainer", msg.text, inc,
|
||||||
@@ -2100,11 +2119,7 @@ namespace FRG {
|
|||||||
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
|
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
|
||||||
if (activeChatIndex_ < 0) return;
|
if (activeChatIndex_ < 0) return;
|
||||||
|
|
||||||
auto& history = gameState_.chatHistory[activeChatIndex_];
|
server_.submit(AppendChatMessageCommand{activeChatIndex_, text, incoming});
|
||||||
if (static_cast<int>(history.size()) >= 5) {
|
|
||||||
history.erase(history.begin());
|
|
||||||
}
|
|
||||||
history.push_back({ text, incoming });
|
|
||||||
|
|
||||||
if (uiState_ != GameUiState::PhoneScreen) return;
|
if (uiState_ != GameUiState::PhoneScreen) return;
|
||||||
|
|
||||||
@@ -2143,8 +2158,8 @@ namespace FRG {
|
|||||||
{
|
{
|
||||||
logger() << "MenuManager::setDarklandsMode called" << std::endl;
|
logger() << "MenuManager::setDarklandsMode called" << std::endl;
|
||||||
|
|
||||||
if (enabled && gameState_.currentLocationName == "uni_interior" && inventory->hasItem("note_spell"))
|
if (enabled && server_.snapshot()->currentLocationId == "uni_interior" && hasInventoryItem("note_spell"))
|
||||||
gameState_.completeTutorialStep(TutorialStep::EnterDarklands);
|
server_.submit(CompleteTutorialStepCommand{TutorialStep::EnterDarklands});
|
||||||
|
|
||||||
if (uiState_ == GameUiState::Gameplay)
|
if (uiState_ == GameUiState::Gameplay)
|
||||||
showGameplayHud(); // darklands has its own HUD layouts
|
showGameplayHud(); // darklands has its own HUD layouts
|
||||||
@@ -2152,30 +2167,31 @@ namespace FRG {
|
|||||||
|
|
||||||
void MenuManager::onPlayerStartedWalking()
|
void MenuManager::onPlayerStartedWalking()
|
||||||
{
|
{
|
||||||
if (gameState_.currentLocationName == "uni_interior" && gameState_.isDarklands)
|
const auto state = server_.snapshot();
|
||||||
gameState_.completeTutorialStep(TutorialStep::DarklandsHealth);
|
if (state->currentLocationId == "uni_interior" && state->isDarklands)
|
||||||
|
server_.submit(CompleteTutorialStepCommand{TutorialStep::DarklandsHealth});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::advanceUniIntDarklandsHud()
|
void MenuManager::advanceUniIntDarklandsHud()
|
||||||
{
|
{
|
||||||
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
|
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
|
||||||
if (gameState_.tutorialStep == TutorialStep::DarklandsHealth
|
const TutorialStep step = server_.snapshot()->tutorialStep;
|
||||||
|| gameState_.tutorialStep == TutorialStep::DarklandsExplore)
|
if (step == TutorialStep::DarklandsHealth || step == TutorialStep::DarklandsExplore)
|
||||||
gameState_.tutorialStep = TutorialStep::DarklandsAttack;
|
server_.submit(SetTutorialStepCommand{TutorialStep::DarklandsAttack});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::onEnemyKilledInUniInterior()
|
void MenuManager::onEnemyKilledInUniInterior()
|
||||||
{
|
{
|
||||||
// Every location's enemies report here; only the university interior drives the tutorial.
|
// 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.
|
// The last tutorial step; like the attack hint, also ends the earlier darklands hints.
|
||||||
if (gameState_.tutorialStep >= TutorialStep::DarklandsHealth)
|
if (state->tutorialStep >= TutorialStep::DarklandsHealth)
|
||||||
gameState_.tutorialStep = TutorialStep::Completed;
|
server_.submit(SetTutorialStepCommand{TutorialStep::Completed});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::updateHealthBar(float hp, float maxHp) {
|
void MenuManager::updateHealthBar(float hp, float maxHp) {
|
||||||
gameState_.playerHp = hp;
|
server_.submit(SetPlayerHealthCommand{hp, maxHp});
|
||||||
gameState_.playerMaxHp = maxHp;
|
|
||||||
applyCurrentHealthBar();
|
applyCurrentHealthBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2191,21 +2207,22 @@ namespace FRG {
|
|||||||
cutsceneHudActive_ = false;
|
cutsceneHudActive_ = false;
|
||||||
topUiManager.replaceRoot(nullptr);
|
topUiManager.replaceRoot(nullptr);
|
||||||
if (uiState_ == GameUiState::Gameplay)
|
if (uiState_ == GameUiState::Gameplay)
|
||||||
onLocationChanged(gameState_.currentLocationName);
|
onLocationChanged(server_.snapshot()->currentLocationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MenuManager::applyCurrentHealthBar() {
|
void MenuManager::applyCurrentHealthBar() {
|
||||||
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
|
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
|
||||||
logger() << "currentPlayerMaxHp_ is " << gameState_.playerMaxHp << std::endl;
|
const auto state = server_.snapshot();
|
||||||
if (gameState_.playerMaxHp <= 0.f) return;
|
logger() << "currentPlayerMaxHp_ is " << state->playerMaxHp << std::endl;
|
||||||
|
if (state->playerMaxHp <= 0.f) return;
|
||||||
logger() << "MenuManager::applyCurrentHealthBar called step 2" << std::endl;
|
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);
|
uiManager.setSliderValue("healthBarFill", fraction);
|
||||||
std::string hpText = std::to_string(static_cast<int>(gameState_.playerHp)) + "/" +
|
std::string hpText = std::to_string(static_cast<int>(state->playerHp)) + "/" +
|
||||||
std::to_string(static_cast<int>(gameState_.playerMaxHp));
|
std::to_string(static_cast<int>(state->playerMaxHp));
|
||||||
if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' ');
|
if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' ');
|
||||||
uiManager.setText("healthValue", hpText);
|
uiManager.setText("healthValue", hpText);
|
||||||
}
|
}
|
||||||
@@ -2447,7 +2464,7 @@ namespace FRG {
|
|||||||
|
|
||||||
openInventory();
|
openInventory();
|
||||||
|
|
||||||
const int itemCount = static_cast<int>(inventory->getItems().size());
|
const int itemCount = static_cast<int>(inventoryItems().size());
|
||||||
if (isPortraitMode() && wasOnItemDetail &&
|
if (isPortraitMode() && wasOnItemDetail &&
|
||||||
savedIndex >= 0 && savedIndex < itemCount) {
|
savedIndex >= 0 && savedIndex < itemCount) {
|
||||||
selectInventoryItem(savedIndex);
|
selectInventoryItem(savedIndex);
|
||||||
|
|||||||
+12
-11
@@ -3,8 +3,9 @@
|
|||||||
#include "Environment.h"
|
#include "Environment.h"
|
||||||
#include "render/TextureManager.h"
|
#include "render/TextureManager.h"
|
||||||
#include "UiManager.h"
|
#include "UiManager.h"
|
||||||
|
#include "Localization.h"
|
||||||
#include "items/Item.h"
|
#include "items/Item.h"
|
||||||
#include "GameState.h"
|
#include "game_server/IGameServer.h"
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -39,9 +40,9 @@ namespace FRG {
|
|||||||
std::unordered_map<std::string,
|
std::unordered_map<std::string,
|
||||||
std::unordered_map<Language, std::string>> localizedLocationName;
|
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 openInventory();
|
||||||
void selectInventoryItem(int index);
|
void selectInventoryItem(int index);
|
||||||
@@ -56,6 +57,8 @@ namespace FRG {
|
|||||||
|
|
||||||
void showMainMenu();
|
void showMainMenu();
|
||||||
void showMenuAfterGameCompleted();
|
void showMenuAfterGameCompleted();
|
||||||
|
// Returns to the main menu with the campaign list already open.
|
||||||
|
void showCampaignScreen();
|
||||||
bool isMainMenuOpen() const { return uiState_ == GameUiState::MainMenu; }
|
bool isMainMenuOpen() const { return uiState_ == GameUiState::MainMenu; }
|
||||||
|
|
||||||
void openPhoneScreen();
|
void openPhoneScreen();
|
||||||
@@ -80,7 +83,7 @@ namespace FRG {
|
|||||||
void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = "");
|
void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = "");
|
||||||
|
|
||||||
void spendMoney(int amount);
|
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).
|
// Starts a new game of the given campaign (see CampaignRegistry).
|
||||||
std::function<void(const std::string& campaignId)> startGameFunc;
|
std::function<void(const std::string& campaignId)> startGameFunc;
|
||||||
@@ -89,7 +92,6 @@ namespace FRG {
|
|||||||
std::function<void()> startNightTransitionFunc;
|
std::function<void()> startNightTransitionFunc;
|
||||||
std::function<void(int)> chatOpenCallback;
|
std::function<void(int)> chatOpenCallback;
|
||||||
std::function<void()> skipCutsceneFunc;
|
std::function<void()> skipCutsceneFunc;
|
||||||
std::function<void()> callTaxiFunc;
|
|
||||||
std::function<void(int)> onSaveGame;
|
std::function<void(int)> onSaveGame;
|
||||||
std::function<void(int)> onLoadGame;
|
std::function<void(int)> onLoadGame;
|
||||||
std::function<SaveSlotInfo(int)> getSlotInfoFunc;
|
std::function<SaveSlotInfo(int)> getSlotInfoFunc;
|
||||||
@@ -172,14 +174,14 @@ namespace FRG {
|
|||||||
Renderer& renderer;
|
Renderer& renderer;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
GameState& gameState_;
|
IGameServer& server_;
|
||||||
AudioPlayerAsync& audioPlayer_;
|
AudioPlayerAsync& audioPlayer_;
|
||||||
|
|
||||||
void startNewGame(const std::string& campaignId);
|
void startNewGame(const std::string& campaignId);
|
||||||
// HUD layout for the current location and darklands mode.
|
// HUD layout for the current location and darklands mode.
|
||||||
std::shared_ptr<UiNode> gameplayHudRoot() const;
|
std::shared_ptr<UiNode> gameplayHudRoot() const;
|
||||||
// Single place that decides which HUD buttons and tutorial hints are visible:
|
// 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.
|
// tutorial flags. Runs every frame; names absent from the shown screen are skipped.
|
||||||
void refreshHudVisibility();
|
void refreshHudVisibility();
|
||||||
void refreshQuestJournalUi();
|
void refreshQuestJournalUi();
|
||||||
@@ -223,8 +225,6 @@ namespace FRG {
|
|||||||
void showLanguageScreen();
|
void showLanguageScreen();
|
||||||
void showLoadGameScreen();
|
void showLoadGameScreen();
|
||||||
void showSaveGameScreen();
|
void showSaveGameScreen();
|
||||||
// Campaign list opened by "Start game".
|
|
||||||
void showCampaignScreen();
|
|
||||||
// Text of a load/save slot button.
|
// Text of a load/save slot button.
|
||||||
std::string slotLabel(const SaveSlotInfo& info);
|
std::string slotLabel(const SaveSlotInfo& info);
|
||||||
|
|
||||||
@@ -249,7 +249,8 @@ namespace FRG {
|
|||||||
static constexpr float TOAST_VISIBLE_MS = 2000.0f;
|
static constexpr float TOAST_VISIBLE_MS = 2000.0f;
|
||||||
|
|
||||||
GameUiState uiState_ = GameUiState::Gameplay;
|
GameUiState uiState_ = GameUiState::Gameplay;
|
||||||
Inventory* inventory = nullptr;
|
std::vector<Item> inventoryItems() const;
|
||||||
|
bool hasInventoryItem(const std::string& id) const;
|
||||||
bool isPlayerInCreditsMenu = false;
|
bool isPlayerInCreditsMenu = false;
|
||||||
int inventorySelectedIndex_ = -1;
|
int inventorySelectedIndex_ = -1;
|
||||||
|
|
||||||
@@ -336,7 +337,7 @@ namespace FRG {
|
|||||||
enum class PhoneSubScreen { None, Main, Bank, Video, MapDorm, MapUni, ChatList, Chat };
|
enum class PhoneSubScreen { None, Main, Bank, Video, MapDorm, MapUni, ChatList, Chat };
|
||||||
PhoneSubScreen currentPhoneSubScreen_ = PhoneSubScreen::None;
|
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 {
|
struct PhoneChatBubbleInfo {
|
||||||
std::string nodeName;
|
std::string nodeName;
|
||||||
float height;
|
float height;
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "render/Renderer.h"
|
#include <Eigen/Core>
|
||||||
#include "Environment.h"
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
struct NpcCar
|
struct NpcCar
|
||||||
|
|||||||
+328
-347
File diff suppressed because it is too large.
Load diff
+9
-24
@@ -1,9 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <unordered_map>
|
|
||||||
#include <functional>
|
|
||||||
#include "quest/QuestJournal.h"
|
|
||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
#include "AudioPlayerAsync.h"
|
#include "AudioPlayerAsync.h"
|
||||||
@@ -11,37 +8,33 @@
|
|||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
class Location;
|
class Location;
|
||||||
class Inventory;
|
|
||||||
|
|
||||||
class ScriptEngine : public ISaveable {
|
class ScriptEngine {
|
||||||
public:
|
public:
|
||||||
ScriptEngine();
|
ScriptEngine();
|
||||||
~ScriptEngine();
|
~ScriptEngine();
|
||||||
|
|
||||||
// Must be called once, after the Game's NPCs are ready.
|
// 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.
|
// Execute a Lua script file. Logs errors to stderr.
|
||||||
//void runScript(const std::string& path);
|
//void runScript(const std::string& path);
|
||||||
|
|
||||||
void callItemPickupCallback(const std::string& objectName);
|
void callItemPickupCallback(const std::string& objectName);
|
||||||
|
void callFunction(const std::string& functionName);
|
||||||
void callActivateFunction(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 callTriggerEnterCallback(const std::string& zoneId);
|
||||||
void callTriggerExitCallback(const std::string& zoneId);
|
void callTriggerExitCallback(const std::string& zoneId);
|
||||||
|
|
||||||
// Fired when the player (moving) physically bumps into a standing NPC.
|
// 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.
|
// Fired when a moving NPC physically bumps into the standing player.
|
||||||
void callNpcBumpsPlayerCallback(int npcIndex);
|
void callNpcBumpsPlayerCallback(const std::string& npcId);
|
||||||
|
|
||||||
void setGlobalStore(std::unordered_map<std::string, int>* store);
|
|
||||||
void setGlobalFloatStore(std::unordered_map<std::string, float>* store);
|
|
||||||
void setQuestJournal(Quest::QuestJournal* journal);
|
|
||||||
|
|
||||||
|
void callLocationInitCallback();
|
||||||
void callLocationEnterCallback();
|
void callLocationEnterCallback();
|
||||||
void callLocationExitCallback();
|
void callLocationExitCallback();
|
||||||
|
|
||||||
@@ -63,16 +56,8 @@ public:
|
|||||||
// has already run and registered all callbacks.
|
// has already run and registered all callbacks.
|
||||||
void loadScriptGlobals(const nlohmann::json& in);
|
void loadScriptGlobals(const nlohmann::json& in);
|
||||||
|
|
||||||
// Re-wires an in-flight, one-shot callback (NPC/player walk-arrival, object
|
void save(nlohmann::json& out) const { saveScriptGlobals(out); }
|
||||||
// animation-complete) that was serialized by name rather than by value.
|
void load(const nlohmann::json& in) { loadScriptGlobals(in); }
|
||||||
// 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); }
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Impl;
|
struct Impl;
|
||||||
|
|||||||
+3
-3
@@ -51,13 +51,13 @@ namespace FRG {
|
|||||||
std::string shaderProgramName;
|
std::string shaderProgramName;
|
||||||
|
|
||||||
// Sparks spawn within this radius of each emission point. 0 means
|
// 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;
|
float emissionRadius;
|
||||||
|
|
||||||
bool configured;
|
bool configured;
|
||||||
// Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity,
|
// Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity,
|
||||||
// particles are rendered as axis-aligned quads in world XY and sorted by
|
// 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
|
// camera using its world-space right/up axes, and particles are sorted
|
||||||
// back-to-front in eye space for correct alpha blending.
|
// back-to-front in eye space for correct alpha blending.
|
||||||
void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix);
|
void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix);
|
||||||
@@ -106,7 +106,7 @@ namespace FRG {
|
|||||||
// Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO.
|
// Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO.
|
||||||
// Pass the current camera view matrix (world → eye) to billboard sparks
|
// Pass the current camera view matrix (world → eye) to billboard sparks
|
||||||
// toward the camera and sort them back-to-front. Identity (default)
|
// 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 prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix = Eigen::Matrix4f::Identity());
|
||||||
|
|
||||||
void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight);
|
void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight);
|
||||||
|
|||||||
+2
-2
@@ -287,10 +287,10 @@ namespace FRG {
|
|||||||
float x1, u1;
|
float x1, u1;
|
||||||
if (fillMode) {
|
if (fillMode) {
|
||||||
const float clamped = std::clamp(value, 0.0f, 1.0f);
|
const float clamped = std::clamp(value, 0.0f, 1.0f);
|
||||||
if (clamped <= 0.0f) {
|
/*if (clamped <= 0.0f) {
|
||||||
trackMesh.RefreshVBO();
|
trackMesh.RefreshVBO();
|
||||||
return;
|
return;
|
||||||
}
|
}*/
|
||||||
x1 = rect.x + rect.w * clamped;
|
x1 = rect.x + rect.w * clamped;
|
||||||
u1 = clamped;
|
u1 = clamped;
|
||||||
} else {
|
} 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) {
|
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 {
|
int DialogueRuntime::getFlag(const std::string& name) const {
|
||||||
if (!flagStore) return 0;
|
return getGlobalFlag ? getGlobalFlag(name) : 0;
|
||||||
auto it = flagStore->find(name);
|
|
||||||
return (it != flagStore->end()) ? it->second : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DialogueRuntime::setGlobalFlagStore(std::unordered_map<std::string, int>* store) {
|
void DialogueRuntime::setGlobalFlagAccessors(
|
||||||
flagStore = store;
|
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) {
|
void DialogueRuntime::setQuestActionHandler(
|
||||||
questJournal = journal;
|
std::function<void(const QuestActionRequest&)> handler) {
|
||||||
|
onQuestAction = std::move(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DialogueRuntime::applyQuestActions(
|
void DialogueRuntime::applyQuestActions(
|
||||||
@@ -184,17 +187,20 @@ void DialogueRuntime::applyQuestActions(
|
|||||||
const std::string& questFail, const std::string& objectiveComplete,
|
const std::string& questFail, const std::string& objectiveComplete,
|
||||||
const std::string& objectiveVisible)
|
const std::string& objectiveVisible)
|
||||||
{
|
{
|
||||||
if (!questJournal) return;
|
if (!onQuestAction) return;
|
||||||
if (!questUnlock.empty()) questJournal->unlockQuest(questUnlock);
|
if (!questUnlock.empty())
|
||||||
if (!questComplete.empty()) questJournal->completeQuest(questComplete);
|
onQuestAction({QuestActionType::Unlock, questUnlock, {}});
|
||||||
if (!questFail.empty()) questJournal->failQuest(questFail);
|
if (!questComplete.empty())
|
||||||
|
onQuestAction({QuestActionType::Complete, questComplete, {}});
|
||||||
|
if (!questFail.empty())
|
||||||
|
onQuestAction({QuestActionType::Fail, questFail, {}});
|
||||||
if (!objectiveComplete.empty()) {
|
if (!objectiveComplete.empty()) {
|
||||||
auto [qId, oId] = splitDot(objectiveComplete);
|
auto [qId, oId] = splitDot(objectiveComplete);
|
||||||
questJournal->setObjectiveCompleted(qId, oId);
|
onQuestAction({QuestActionType::CompleteObjective, qId, oId});
|
||||||
}
|
}
|
||||||
if (!objectiveVisible.empty()) {
|
if (!objectiveVisible.empty()) {
|
||||||
auto [qId, oId] = splitDot(objectiveVisible);
|
auto [qId, oId] = splitDot(objectiveVisible);
|
||||||
questJournal->setObjectiveVisible(qId, oId);
|
onQuestAction({QuestActionType::ShowObjective, qId, oId});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,29 @@
|
|||||||
|
|
||||||
#include "dialogue/DialogueDatabase.h"
|
#include "dialogue/DialogueDatabase.h"
|
||||||
#include "dialogue/TranslationDatabase.h"
|
#include "dialogue/TranslationDatabase.h"
|
||||||
#include "quest/QuestJournal.h"
|
|
||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace FRG::Dialogue {
|
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 {
|
class DialogueRuntime : public FRG::ISaveable {
|
||||||
public:
|
public:
|
||||||
void setDatabase(const DialogueDatabase* value);
|
void setDatabase(const DialogueDatabase* value);
|
||||||
@@ -41,8 +53,11 @@ public:
|
|||||||
|
|
||||||
void setFlag(const std::string& name, int value);
|
void setFlag(const std::string& name, int value);
|
||||||
int getFlag(const std::string& name) const;
|
int getFlag(const std::string& name) const;
|
||||||
void setGlobalFlagStore(std::unordered_map<std::string, int>* store);
|
void setGlobalFlagAccessors(
|
||||||
void setQuestJournal(Quest::QuestJournal* journal);
|
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.
|
// Saves whether a dialogue is active, its id, and current node.
|
||||||
// Cutscene state is not saved.
|
// 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&, const std::string&)> onCutsceneStartNeeded;
|
||||||
std::function<void(const std::string&)> onDialogueLineStarted;
|
std::function<void(const std::string&)> onDialogueLineStarted;
|
||||||
std::function<void(const std::string&, bool)> onChatBubbleReady;
|
std::function<void(const std::string&, bool)> onChatBubbleReady;
|
||||||
|
std::function<void(const QuestActionRequest&)> onQuestAction;
|
||||||
|
|
||||||
const DialogueDatabase* database = nullptr;
|
const DialogueDatabase* database = nullptr;
|
||||||
const TranslationDatabase* translations = nullptr;
|
const TranslationDatabase* translations = nullptr;
|
||||||
Quest::QuestJournal* questJournal = nullptr;
|
|
||||||
const DialogueDefinition* activeDialogue = 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::unordered_set<std::string> consumedChoices;
|
||||||
|
|
||||||
std::string currentNodeId;
|
std::string currentNodeId;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
#include "cutscene/CutsceneDatabase.h"
|
#include "cutscene/CutsceneDatabase.h"
|
||||||
#include "cutscene/CutsceneOverlay.h"
|
#include "cutscene/CutsceneOverlay.h"
|
||||||
#include "cutscene/CutsceneRuntime.h"
|
#include "cutscene/CutsceneRuntime.h"
|
||||||
#include "quest/QuestJournal.h"
|
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
|
|
||||||
namespace FRG::Dialogue {
|
namespace FRG::Dialogue {
|
||||||
|
|
||||||
class DialogueSystem : public FRG::ISaveable {
|
class DialogueSystem {
|
||||||
public:
|
public:
|
||||||
bool init(Renderer& renderer, const std::string& zipFile = "");
|
bool init(Renderer& renderer, const std::string& zipFile = "");
|
||||||
|
|
||||||
@@ -48,8 +47,8 @@ public:
|
|||||||
void saveDialogueState(nlohmann::json& out) const { dialogueRuntime.save(out); }
|
void saveDialogueState(nlohmann::json& out) const { dialogueRuntime.save(out); }
|
||||||
void loadDialogueState(const nlohmann::json& in) { dialogueRuntime.load(in); }
|
void loadDialogueState(const nlohmann::json& in) { dialogueRuntime.load(in); }
|
||||||
|
|
||||||
void save(nlohmann::json& out) const override { saveDialogueState(out); }
|
void save(nlohmann::json& out) const { saveDialogueState(out); }
|
||||||
void load(const nlohmann::json& in) override { loadDialogueState(in); }
|
void load(const nlohmann::json& in) { loadDialogueState(in); }
|
||||||
|
|
||||||
bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); }
|
bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); }
|
||||||
bool isDialogueActive() const { return dialogueRuntime.isActive(); }
|
bool isDialogueActive() const { return dialogueRuntime.isActive(); }
|
||||||
@@ -58,11 +57,15 @@ public:
|
|||||||
void setFlag(const std::string& name, int value) { dialogueRuntime.setFlag(name, value); }
|
void setFlag(const std::string& name, int value) { dialogueRuntime.setFlag(name, value); }
|
||||||
int getFlag(const std::string& name) const { return dialogueRuntime.getFlag(name); }
|
int getFlag(const std::string& name) const { return dialogueRuntime.getFlag(name); }
|
||||||
|
|
||||||
void setGlobalFlagStore(std::unordered_map<std::string, int>* store) {
|
void setGlobalFlagAccessors(
|
||||||
dialogueRuntime.setGlobalFlagStore(store);
|
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) {
|
void setQuestActionHandler(
|
||||||
dialogueRuntime.setQuestJournal(journal);
|
std::function<void(const QuestActionRequest&)> handler) {
|
||||||
|
dialogueRuntime.setQuestActionHandler(std::move(handler));
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
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 <Eigen/Geometry>
|
||||||
#include "../Character.h"
|
#include "../Character.h"
|
||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
@@ -91,6 +92,9 @@ namespace FRG {
|
|||||||
|
|
||||||
for (const auto& item : j["objects"]) {
|
for (const auto& item : j["objects"]) {
|
||||||
InteractiveObjectData data;
|
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.base = parseGameObjectData(item);
|
||||||
data.interactionRadius = item.value("interactionRadius", 2.0f);
|
data.interactionRadius = item.value("interactionRadius", 2.0f);
|
||||||
data.approachRadius = item.value("approachRadius", data.interactionRadius);
|
data.approachRadius = item.value("approachRadius", data.interactionRadius);
|
||||||
@@ -98,20 +102,21 @@ namespace FRG {
|
|||||||
data.castShadowNight = item.value("castShadowNight", true);
|
data.castShadowNight = item.value("castShadowNight", true);
|
||||||
data.isActive = item.value("isActive", true);
|
data.isActive = item.value("isActive", true);
|
||||||
data.activateFunctionName = item.value("activateFunction", "");
|
data.activateFunctionName = item.value("activateFunction", "");
|
||||||
data.pivotX = item.value("pivotX", 0.0f);
|
data.pivot = {
|
||||||
data.pivotY = item.value("pivotY", 0.0f);
|
item.value("pivotX", 0.0f), item.value("pivotY", 0.0f),
|
||||||
data.pivotZ = item.value("pivotZ", 0.0f);
|
item.value("pivotZ", 0.0f)};
|
||||||
data.boundsMinX = item.value("boundsMinX", 0.0f);
|
data.boundsMin = {
|
||||||
data.boundsMinY = item.value("boundsMinY", 0.0f);
|
item.value("boundsMinX", 0.0f), item.value("boundsMinY", 0.0f),
|
||||||
data.boundsMinZ = item.value("boundsMinZ", 0.0f);
|
item.value("boundsMinZ", 0.0f)};
|
||||||
data.boundsMaxX = item.value("boundsMaxX", 0.0f);
|
data.boundsMax = {
|
||||||
data.boundsMaxY = item.value("boundsMaxY", 0.0f);
|
item.value("boundsMaxX", 0.0f), item.value("boundsMaxY", 0.0f),
|
||||||
data.boundsMaxZ = item.value("boundsMaxZ", 0.0f);
|
item.value("boundsMaxZ", 0.0f)};
|
||||||
if (item.contains("interactionPositionX")) {
|
if (item.contains("interactionPositionX")) {
|
||||||
data.hasInteractionPosition = true;
|
data.hasInteractionPosition = true;
|
||||||
data.interactionPositionX = item.value("interactionPositionX", 0.0f);
|
data.interactionPosition = {
|
||||||
data.interactionPositionY = item.value("interactionPositionY", 0.0f);
|
item.value("interactionPositionX", 0.0f),
|
||||||
data.interactionPositionZ = item.value("interactionPositionZ", 0.0f);
|
item.value("interactionPositionY", 0.0f),
|
||||||
|
item.value("interactionPositionZ", 0.0f)};
|
||||||
}
|
}
|
||||||
if (!data.base.meshPath.empty())
|
if (!data.base.meshPath.empty())
|
||||||
objects.push_back(std::move(data));
|
objects.push_back(std::move(data));
|
||||||
@@ -121,40 +126,6 @@ namespace FRG {
|
|||||||
return objects;
|
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)
|
static VertexDataStruct loadMeshData(const std::string& meshPath, const std::string& zipPath)
|
||||||
{
|
{
|
||||||
if (meshPath.size() > 4 && meshPath.compare(meshPath.size() - 4, 4, ".bin") == 0)
|
if (meshPath.size() > 4 && meshPath.compare(meshPath.size() - 4, 4, ".bin") == 0)
|
||||||
@@ -209,20 +180,18 @@ namespace FRG {
|
|||||||
// Public API
|
// Public API
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
|
StaticObjectSet GameObjectLoader::createGameObjects(
|
||||||
const std::string& jsonPath,
|
const std::vector<GameObjectData>& definitions,
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
const std::string& zipPath)
|
const std::string& zipPath)
|
||||||
{
|
{
|
||||||
StaticObjectSet result;
|
StaticObjectSet result;
|
||||||
|
|
||||||
std::vector<GameObjectData> allData = loadFromJson(jsonPath, zipPath);
|
|
||||||
|
|
||||||
// Group objects sharing mesh + textures (in file order) so they can be drawn instanced.
|
// Group objects sharing mesh + textures (in file order) so they can be drawn instanced.
|
||||||
std::vector<std::vector<const GameObjectData*>> groups;
|
std::vector<std::vector<const GameObjectData*>> groups;
|
||||||
std::unordered_map<std::string, size_t> groupIndexByKey;
|
std::unordered_map<std::string, size_t> groupIndexByKey;
|
||||||
|
|
||||||
for (const auto& data : allData) {
|
for (const auto& data : definitions) {
|
||||||
if (!data.instanced) {
|
if (!data.instanced) {
|
||||||
groups.push_back({ &data });
|
groups.push_back({ &data });
|
||||||
continue;
|
continue;
|
||||||
@@ -290,22 +259,33 @@ namespace FRG {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<InteractiveObject> GameObjectLoader::loadAndCreateInteractiveObjects(
|
StaticObjectSet GameObjectLoader::loadAndCreateGameObjects(
|
||||||
const std::string& jsonPath,
|
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,
|
Renderer& renderer,
|
||||||
|
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
|
||||||
const std::string& zipPath)
|
const std::string& zipPath)
|
||||||
{
|
{
|
||||||
std::vector<InteractiveObject> interactiveObjects;
|
std::vector<InteractiveObject> interactiveObjects;
|
||||||
|
for (const auto& data : definitions) {
|
||||||
if (jsonPath.empty())
|
|
||||||
return interactiveObjects;
|
|
||||||
|
|
||||||
for (const auto& data : loadInteractiveFromJson(jsonPath, zipPath)) {
|
|
||||||
SDL_PumpEvents();
|
SDL_PumpEvents();
|
||||||
logger() << "Loading interactive object: " << data.base.name << std::endl;
|
logger() << "Loading interactive object: " << data.base.name << std::endl;
|
||||||
try {
|
try {
|
||||||
InteractiveObject intObj = InteractiveObject::createFromState(
|
auto state = states.find(data.id);
|
||||||
data.toInteractiveObjectState(), renderer, zipPath);
|
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;
|
intObj.loadedObject.name = data.base.name;
|
||||||
|
|
||||||
if (!data.activateFunctionName.empty())
|
if (!data.activateFunctionName.empty())
|
||||||
@@ -324,6 +304,18 @@ namespace FRG {
|
|||||||
return interactiveObjects;
|
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> GameObjectLoader::loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath)
|
||||||
{
|
{
|
||||||
std::vector<NpcData> npcs;
|
std::vector<NpcData> npcs;
|
||||||
@@ -338,7 +330,9 @@ namespace FRG {
|
|||||||
for (const auto& item : j["npcs"]) {
|
for (const auto& item : j["npcs"]) {
|
||||||
NpcData data;
|
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.name = item.value("name", "Unknown NPC");
|
||||||
data.texturePath = item.value("texturePath", "");
|
data.texturePath = item.value("texturePath", "");
|
||||||
if (item.contains("meshTextures") && item["meshTextures"].is_object()) {
|
if (item.contains("meshTextures") && item["meshTextures"].is_object()) {
|
||||||
@@ -376,88 +370,59 @@ namespace FRG {
|
|||||||
logger() << "Successfully loaded " << npcs.size() << " NPCs from " << jsonPath << std::endl;
|
logger() << "Successfully loaded " << npcs.size() << " NPCs from " << jsonPath << std::endl;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "Error loading NPCs from JSON: " << e.what() << std::endl;
|
std::cerr << "Error loading NPCs from JSON: " << e.what() << std::endl;
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return npcs;
|
return npcs;
|
||||||
}
|
}
|
||||||
|
|
||||||
CharacterState NpcData::toCharacterState() const {
|
std::vector<std::unique_ptr<Character>> GameObjectLoader::createNpcs(
|
||||||
CharacterState st;
|
const std::vector<NpcData>& definitions,
|
||||||
|
|
||||||
// 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,
|
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
|
const std::unordered_map<std::string,
|
||||||
|
std::shared_ptr<CharacterState>>& states,
|
||||||
|
IGameServer& server,
|
||||||
|
const std::string& locationId,
|
||||||
const std::string& zipPath)
|
const std::string& zipPath)
|
||||||
{
|
{
|
||||||
std::vector<std::unique_ptr<Character>> npcs;
|
std::vector<std::unique_ptr<Character>> npcs;
|
||||||
|
|
||||||
int index = 0;
|
for (const auto& npcData : definitions) {
|
||||||
|
|
||||||
for (const auto& npcData : loadNpcsFromJson(jsonPath, zipPath)) {
|
|
||||||
SDL_PumpEvents();
|
SDL_PumpEvents();
|
||||||
logger() << "Loading NPC: " << npcData.name << std::endl;
|
logger() << "Loading NPC: " << npcData.name << std::endl;
|
||||||
try {
|
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) {
|
if (npc) {
|
||||||
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
|
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
|
||||||
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
|
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
|
||||||
npc->index = index;
|
|
||||||
npcs.push_back(std::move(npc));
|
npcs.push_back(std::move(npc));
|
||||||
index = index + 1;
|
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "GameObjectLoader: Failed to load NPC '" << npcData.name << "': " << e.what() << std::endl;
|
std::cerr << "GameObjectLoader: Failed to load NPC '" << npcData.name << "': " << e.what() << std::endl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger() << "Total NPCs loaded: " << npcs.size() << std::endl;
|
logger() << "Total NPCs loaded: " << npcs.size() << std::endl;
|
||||||
return npcs;
|
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
|
} // namespace FRG
|
||||||
@@ -9,30 +9,13 @@
|
|||||||
#include "InteractiveObject.h"
|
#include "InteractiveObject.h"
|
||||||
#include "InteractiveObjectState.h"
|
#include "InteractiveObjectState.h"
|
||||||
#include "../CharacterState.h"
|
#include "../CharacterState.h"
|
||||||
|
#include "../content/LocationDefinition.h"
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
class IGameServer;
|
||||||
|
|
||||||
class Character;
|
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,
|
// Result of loading a static object list: unique objects keep their own mesh,
|
||||||
// repeated ones are merged into instanced batches.
|
// repeated ones are merged into instanced batches.
|
||||||
struct StaticObjectSet {
|
struct StaticObjectSet {
|
||||||
@@ -40,62 +23,25 @@ namespace FRG {
|
|||||||
std::vector<InstancedBatch> batches;
|
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 {
|
class GameObjectLoader {
|
||||||
public:
|
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(
|
static StaticObjectSet loadAndCreateGameObjects(
|
||||||
const std::string& jsonPath,
|
const std::string& jsonPath,
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
@@ -105,13 +51,17 @@ namespace FRG {
|
|||||||
static std::vector<InteractiveObject> loadAndCreateInteractiveObjects(
|
static std::vector<InteractiveObject> loadAndCreateInteractiveObjects(
|
||||||
const std::string& jsonPath,
|
const std::string& jsonPath,
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
|
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
|
||||||
const std::string& zipPath = ""
|
const std::string& zipPath = ""
|
||||||
);
|
);
|
||||||
|
|
||||||
static std::vector<std::unique_ptr<FRG::Character>> loadAndCreate_Npcs(
|
static std::vector<std::unique_ptr<FRG::Character>> loadAndCreate_Npcs(
|
||||||
const std::string& jsonPath,
|
const std::string& jsonPath,
|
||||||
Renderer& renderer,
|
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 = "");
|
static std::vector<NpcData> loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath = "");
|
||||||
|
|||||||
+90
-184
@@ -5,6 +5,8 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <Eigen/Geometry>
|
#include <Eigen/Geometry>
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
@@ -19,12 +21,11 @@ namespace FRG {
|
|||||||
using std::max;
|
using std::max;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState st,
|
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState& st,
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
const std::string& zipPath)
|
const std::string& zipPath)
|
||||||
{
|
{
|
||||||
InteractiveObject obj;
|
InteractiveObject obj(st);
|
||||||
obj.state = std::move(st);
|
|
||||||
|
|
||||||
const LoadedGameObjectState& info = obj.state.objectInfo;
|
const LoadedGameObjectState& info = obj.state.objectInfo;
|
||||||
|
|
||||||
@@ -52,7 +53,8 @@ namespace FRG {
|
|||||||
|
|
||||||
obj.loadedObject.mesh.RefreshVBO();
|
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()) {
|
if (!obj.loadedObject.mesh.data.PositionData.empty()) {
|
||||||
Eigen::Vector3f meshMin = obj.loadedObject.mesh.data.PositionData[0];
|
Eigen::Vector3f meshMin = obj.loadedObject.mesh.data.PositionData[0];
|
||||||
Eigen::Vector3f meshMax = obj.loadedObject.mesh.data.PositionData[0];
|
Eigen::Vector3f meshMax = obj.loadedObject.mesh.data.PositionData[0];
|
||||||
@@ -63,131 +65,77 @@ namespace FRG {
|
|||||||
const Eigen::Vector3f meshCenter = (meshMin + meshMax) * 0.5f;
|
const Eigen::Vector3f meshCenter = (meshMin + meshMax) * 0.5f;
|
||||||
obj.loadedObject.mesh.data.Move(-meshCenter);
|
obj.loadedObject.mesh.data.Move(-meshCenter);
|
||||||
obj.loadedObject.mesh.RefreshVBO();
|
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;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void InteractiveObject::moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete, std::string callbackName) {
|
void InteractiveObject::capturePresentationState()
|
||||||
if (state.isAnimating) return;
|
{
|
||||||
AnimTask task;
|
PresentationSample sample;
|
||||||
task.type = AnimTask::Type::Move;
|
sample.position = state.position;
|
||||||
task.startPos = state.position;
|
sample.rotationY = state.rotationY;
|
||||||
task.startRotY = state.rotationY;
|
sample.scale = state.scale;
|
||||||
task.startScale = state.scale;
|
sample.alpha = state.alpha;
|
||||||
task.targetPos = target;
|
sample.isActive = state.isActive;
|
||||||
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) {
|
const auto now = std::chrono::steady_clock::now();
|
||||||
if (state.isAnimating) return;
|
const bool largeGap = hasPresentationSample_ &&
|
||||||
AnimTask task;
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
task.type = AnimTask::Type::Rotate;
|
now - lastPresentationUpdate_).count() > 250;
|
||||||
task.startPos = state.position;
|
if (!hasPresentationSample_ || largeGap ||
|
||||||
task.startRotY = state.rotationY;
|
currentPresentationSample_.isActive != sample.isActive) {
|
||||||
task.startScale = state.scale;
|
previousPresentationSample_ = sample;
|
||||||
task.targetPos = state.position;
|
} else {
|
||||||
task.targetRotY = targetRotY;
|
previousPresentationSample_ = currentPresentationSample_;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
currentPresentationSample_ = sample;
|
||||||
|
lastPresentationUpdate_ = now;
|
||||||
|
hasPresentationSample_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (t >= 1.0f) {
|
InteractiveObject::PresentationSample
|
||||||
state.position = task.targetPos;
|
InteractiveObject::interpolatedPresentationState() const
|
||||||
state.rotationY = task.targetRotY;
|
{
|
||||||
if (task.type == AnimTask::Type::Fade)
|
PresentationSample result;
|
||||||
state.alpha = task.targetScale;
|
result.position = state.position;
|
||||||
else
|
result.rotationY = state.rotationY;
|
||||||
state.scale = task.targetScale;
|
result.scale = state.scale;
|
||||||
|
result.alpha = state.alpha;
|
||||||
|
result.isActive = state.isActive;
|
||||||
|
if (!hasPresentationSample_) return result;
|
||||||
|
|
||||||
std::function<void()> cb = std::move(task.onComplete);
|
constexpr float kSimulationTickMs = 100.0f;
|
||||||
state.animTask.reset();
|
const float elapsedMs = static_cast<float>(
|
||||||
state.isAnimating = false;
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - lastPresentationUpdate_).count());
|
||||||
|
const float alpha = (std::max)(0.0f,
|
||||||
|
(std::min)(1.0f, elapsedMs / kSimulationTickMs));
|
||||||
|
|
||||||
if (cb) cb();
|
result.position = previousPresentationSample_.position +
|
||||||
}
|
alpha * (currentPresentationSample_.position -
|
||||||
|
previousPresentationSample_.position);
|
||||||
|
result.scale = previousPresentationSample_.scale +
|
||||||
|
alpha * (currentPresentationSample_.scale -
|
||||||
|
previousPresentationSample_.scale);
|
||||||
|
result.alpha = previousPresentationSample_.alpha +
|
||||||
|
alpha * (currentPresentationSample_.alpha -
|
||||||
|
previousPresentationSample_.alpha);
|
||||||
|
|
||||||
|
constexpr float kPi = 3.14159265358979323846f;
|
||||||
|
constexpr float kTwoPi = 2.0f * kPi;
|
||||||
|
float rotationDelta = currentPresentationSample_.rotationY -
|
||||||
|
previousPresentationSample_.rotationY;
|
||||||
|
while (rotationDelta > kPi) rotationDelta -= kTwoPi;
|
||||||
|
while (rotationDelta < -kPi) rotationDelta += kTwoPi;
|
||||||
|
result.rotationY = previousPresentationSample_.rotationY +
|
||||||
|
alpha * rotationDelta;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InteractiveObject::drawDarklands(Renderer& renderer) const {
|
void InteractiveObject::drawDarklands(Renderer& renderer) const {
|
||||||
if (!state.isActive || !loadedObject.textureDarklands) return;
|
if (!state.isActive || !loadedObject.textureDarklands) return;
|
||||||
|
const PresentationSample presentation = interpolatedPresentationState();
|
||||||
|
|
||||||
if (state.alpha < 0.999f) {
|
if (state.alpha < 0.999f) {
|
||||||
glEnable(GL_BLEND);
|
glEnable(GL_BLEND);
|
||||||
@@ -195,16 +143,16 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderer.PushMatrix();
|
renderer.PushMatrix();
|
||||||
renderer.TranslateMatrix(state.position);
|
renderer.TranslateMatrix(presentation.position);
|
||||||
if (state.rotationY != 0.f) {
|
if (presentation.rotationY != 0.f) {
|
||||||
renderer.TranslateMatrix(state.pivot);
|
renderer.TranslateMatrix(state.pivot);
|
||||||
renderer.RotateMatrix(Eigen::AngleAxisf(state.rotationY, Eigen::Vector3f::UnitY()).toRotationMatrix());
|
renderer.RotateMatrix(Eigen::AngleAxisf(presentation.rotationY, Eigen::Vector3f::UnitY()).toRotationMatrix());
|
||||||
renderer.TranslateMatrix(-state.pivot);
|
renderer.TranslateMatrix(-state.pivot);
|
||||||
}
|
}
|
||||||
if (state.scale != 1.f)
|
if (presentation.scale != 1.f)
|
||||||
renderer.ScaleMatrix(state.scale);
|
renderer.ScaleMatrix(presentation.scale);
|
||||||
renderer.RenderUniform1i(textureUniformName, 0);
|
renderer.RenderUniform1i(textureUniformName, 0);
|
||||||
renderer.RenderUniform1f("uAlpha", state.alpha);
|
renderer.RenderUniform1f("uAlpha", presentation.alpha);
|
||||||
glBindTexture(GL_TEXTURE_2D, loadedObject.textureDarklands->getTexID());
|
glBindTexture(GL_TEXTURE_2D, loadedObject.textureDarklands->getTexID());
|
||||||
renderer.DrawVertexRenderStruct(loadedObject.mesh);
|
renderer.DrawVertexRenderStruct(loadedObject.mesh);
|
||||||
renderer.PopMatrix();
|
renderer.PopMatrix();
|
||||||
@@ -214,6 +162,7 @@ namespace FRG {
|
|||||||
|
|
||||||
void InteractiveObject::draw(Renderer& renderer) const {
|
void InteractiveObject::draw(Renderer& renderer) const {
|
||||||
if (!state.isActive || !loadedObject.texture) return;
|
if (!state.isActive || !loadedObject.texture) return;
|
||||||
|
const PresentationSample presentation = interpolatedPresentationState();
|
||||||
|
|
||||||
if (state.alpha < 0.999f) {
|
if (state.alpha < 0.999f) {
|
||||||
glEnable(GL_BLEND);
|
glEnable(GL_BLEND);
|
||||||
@@ -221,16 +170,16 @@ namespace FRG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderer.PushMatrix();
|
renderer.PushMatrix();
|
||||||
renderer.TranslateMatrix(state.position);
|
renderer.TranslateMatrix(presentation.position);
|
||||||
if (state.rotationY != 0.f) {
|
if (presentation.rotationY != 0.f) {
|
||||||
renderer.TranslateMatrix(state.pivot);
|
renderer.TranslateMatrix(state.pivot);
|
||||||
renderer.RotateMatrix(Eigen::AngleAxisf(state.rotationY, Eigen::Vector3f::UnitY()).toRotationMatrix());
|
renderer.RotateMatrix(Eigen::AngleAxisf(presentation.rotationY, Eigen::Vector3f::UnitY()).toRotationMatrix());
|
||||||
renderer.TranslateMatrix(-state.pivot);
|
renderer.TranslateMatrix(-state.pivot);
|
||||||
}
|
}
|
||||||
if (state.scale != 1.f)
|
if (presentation.scale != 1.f)
|
||||||
renderer.ScaleMatrix(state.scale);
|
renderer.ScaleMatrix(presentation.scale);
|
||||||
renderer.RenderUniform1i(textureUniformName, 0);
|
renderer.RenderUniform1i(textureUniformName, 0);
|
||||||
renderer.RenderUniform1f("uAlpha", state.alpha);
|
renderer.RenderUniform1f("uAlpha", presentation.alpha);
|
||||||
glBindTexture(GL_TEXTURE_2D, loadedObject.texture->getTexID());
|
glBindTexture(GL_TEXTURE_2D, loadedObject.texture->getTexID());
|
||||||
renderer.DrawVertexRenderStruct(loadedObject.mesh);
|
renderer.DrawVertexRenderStruct(loadedObject.mesh);
|
||||||
renderer.PopMatrix();
|
renderer.PopMatrix();
|
||||||
@@ -238,66 +187,23 @@ namespace FRG {
|
|||||||
renderer.RenderUniform1f("uAlpha", 1.0f);
|
renderer.RenderUniform1f("uAlpha", 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
void InteractiveObjectState::save(nlohmann::json& out) const
|
void InteractiveObject::drawShadowDepth(Renderer& renderer) const
|
||||||
{
|
{
|
||||||
out["positionX"] = position.x();
|
if (!state.isActive) return;
|
||||||
out["positionY"] = position.y();
|
const PresentationSample presentation = interpolatedPresentationState();
|
||||||
out["positionZ"] = position.z();
|
|
||||||
out["rotationY"] = rotationY;
|
|
||||||
out["scale"] = scale;
|
|
||||||
out["alpha"] = alpha;
|
|
||||||
out["isActive"] = isActive;
|
|
||||||
out["isAnimating"] = isAnimating;
|
|
||||||
|
|
||||||
if (isAnimating && animTask.has_value()) {
|
renderer.PushMatrix();
|
||||||
nlohmann::json at;
|
renderer.TranslateMatrix(presentation.position);
|
||||||
at["type"] = static_cast<int>(animTask->type);
|
if (presentation.rotationY != 0.f) {
|
||||||
at["startPosX"] = animTask->startPos.x();
|
renderer.TranslateMatrix(state.pivot);
|
||||||
at["startPosY"] = animTask->startPos.y();
|
renderer.RotateMatrix(Eigen::AngleAxisf(
|
||||||
at["startPosZ"] = animTask->startPos.z();
|
presentation.rotationY, Eigen::Vector3f::UnitY()).toRotationMatrix());
|
||||||
at["startRotY"] = animTask->startRotY;
|
renderer.TranslateMatrix(-state.pivot);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
if (presentation.scale != 1.f)
|
||||||
|
renderer.ScaleMatrix(presentation.scale);
|
||||||
|
renderer.DrawVertexRenderStruct(loadedObject.mesh);
|
||||||
|
renderer.PopMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace FRG
|
} // namespace FRG
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
|
||||||
#include <functional>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <Eigen/Core>
|
#include <Eigen/Core>
|
||||||
#include "render/Renderer.h"
|
#include "render/Renderer.h"
|
||||||
#include "InteractiveObjectState.h"
|
#include "InteractiveObjectState.h"
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
@@ -21,25 +19,43 @@ namespace FRG {
|
|||||||
};
|
};
|
||||||
|
|
||||||
struct InteractiveObject {
|
struct InteractiveObject {
|
||||||
|
explicit InteractiveObject(InteractiveObjectState& boundState) : state(boundState) {}
|
||||||
|
std::string id;
|
||||||
LoadedGameObject loadedObject; // rendering assets
|
LoadedGameObject loadedObject; // rendering assets
|
||||||
InteractiveObjectState state; // all mutable + creation state
|
|
||||||
|
|
||||||
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
|
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
|
||||||
// Loads textures and mesh from state.objectInfo, computes mesh centering,
|
// Loads textures and centers the mesh around the state position.
|
||||||
// and sets state.position = jsonPosition + meshCenter.
|
static InteractiveObject createFromState(InteractiveObjectState& state,
|
||||||
static InteractiveObject createFromState(InteractiveObjectState st,
|
|
||||||
Renderer& renderer,
|
Renderer& renderer,
|
||||||
const std::string& zipPath = "");
|
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 draw(Renderer& renderer) const;
|
||||||
void drawDarklands(Renderer& renderer) const;
|
void drawDarklands(Renderer& renderer) const;
|
||||||
|
void drawShadowDepth(Renderer& renderer) const;
|
||||||
|
void capturePresentationState();
|
||||||
|
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
|
||||||
|
|
||||||
|
struct PresentationSample {
|
||||||
|
Eigen::Vector3f position = Eigen::Vector3f::Zero();
|
||||||
|
float rotationY = 0.f;
|
||||||
|
float scale = 1.f;
|
||||||
|
float alpha = 1.f;
|
||||||
|
bool isActive = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
PresentationSample previousPresentationSample_;
|
||||||
|
PresentationSample currentPresentationSample_;
|
||||||
|
std::chrono::steady_clock::time_point lastPresentationUpdate_;
|
||||||
|
bool hasPresentationSample_ = false;
|
||||||
|
|
||||||
|
PresentationSample interpolatedPresentationState() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace FRG
|
} // 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
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <functional>
|
|
||||||
#include <Eigen/Core>
|
#include <Eigen/Core>
|
||||||
#include <Eigen/Geometry>
|
#include <Eigen/Geometry>
|
||||||
#include "external/nlohmann/json.hpp"
|
#include "external/nlohmann/json.hpp"
|
||||||
@@ -21,10 +21,11 @@ struct AnimTask {
|
|||||||
float targetScale = 1.f;
|
float targetScale = 1.f;
|
||||||
float durationMs = 1000.f;
|
float durationMs = 1000.f;
|
||||||
float elapsedMs = 0.f;
|
float elapsedMs = 0.f;
|
||||||
std::function<void()> onComplete; // non-serializable; re-wired at runtime by Lua callbacks
|
// Global Lua function called when the animation completes. Empty means none.
|
||||||
// Global Lua function name backing onComplete, if it was sourced from a script
|
std::string onCompleteName;
|
||||||
// (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback can be
|
};
|
||||||
// re-looked-up by name after a save/load; empty if none or non-Lua.
|
|
||||||
|
struct InteractiveObjectUpdateEvents {
|
||||||
std::string onCompleteName;
|
std::string onCompleteName;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +71,12 @@ public:
|
|||||||
bool isAnimating = false;
|
bool isAnimating = false;
|
||||||
std::optional<AnimTask> animTask;
|
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) ---
|
// --- Serialisation (runtime mutable fields only) ---
|
||||||
void save(nlohmann::json& out) const override;
|
void save(nlohmann::json& out) const override;
|
||||||
void load(const nlohmann::json& in) 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 "Item.h"
|
||||||
#include "ItemRegistry.h"
|
#include "ItemRegistry.h"
|
||||||
#include "external/nlohmann/json.hpp"
|
|
||||||
#include <algorithm>
|
|
||||||
#include <iostream>
|
|
||||||
#include "../utils/Utils.h"
|
|
||||||
|
|
||||||
namespace FRG {
|
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> Inventory::getItems() const {
|
||||||
std::vector<Item> result;
|
std::vector<Item> result;
|
||||||
result.reserve(itemIds.size());
|
result.reserve(itemIds.size());
|
||||||
@@ -36,22 +13,4 @@ namespace FRG {
|
|||||||
return result;
|
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
|
} // namespace FRG
|
||||||
+2
-7
@@ -2,7 +2,6 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <functional>
|
|
||||||
#include "ISaveable.h"
|
#include "ISaveable.h"
|
||||||
|
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
@@ -22,8 +21,8 @@ namespace FRG {
|
|||||||
|
|
||||||
class Inventory : public ISaveable {
|
class Inventory : public ISaveable {
|
||||||
public:
|
public:
|
||||||
void addItem(const std::string& itemId);
|
bool addItem(const std::string& itemId);
|
||||||
void removeItem(const std::string& itemId);
|
bool removeItem(const std::string& itemId);
|
||||||
// Resolves current items from ItemRegistry by id, so returned data
|
// Resolves current items from ItemRegistry by id, so returned data
|
||||||
// always reflects the currently loaded (localized) definitions.
|
// always reflects the currently loaded (localized) definitions.
|
||||||
std::vector<Item> getItems() const;
|
std::vector<Item> getItems() const;
|
||||||
@@ -34,10 +33,6 @@ namespace FRG {
|
|||||||
void save(nlohmann::json& out) const override;
|
void save(nlohmann::json& out) const override;
|
||||||
void load(const nlohmann::json& in) 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:
|
private:
|
||||||
std::vector<std::string> itemIds;
|
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
|
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
|
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 {
|
struct QueueNode {
|
||||||
int index = 0;
|
int index = 0;
|
||||||
float priority = 0.0f;
|
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;
|
const int cellCount = gridWidth * gridDepth;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "render/Renderer.h"
|
|
||||||
#include <Eigen/Core>
|
#include <Eigen/Core>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|||||||
+43
-26
@@ -106,38 +106,48 @@ bool QuestJournal::setStatus(const std::string& questId, QuestStatus status) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QuestJournal::unlockQuest(const std::string& questId) {
|
QuestMutationResult QuestJournal::unlockQuest(const std::string& questId) {
|
||||||
|
QuestMutationResult result;
|
||||||
QuestState* quest = findQuest(questId);
|
QuestState* quest = findQuest(questId);
|
||||||
if (!quest) return false;
|
if (!quest) return result;
|
||||||
|
result.success = true;
|
||||||
if (quest->status == QuestStatus::Hidden) {
|
if (quest->status == QuestStatus::Hidden) {
|
||||||
quest->status = QuestStatus::Available;
|
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) {
|
QuestMutationResult QuestJournal::completeQuest(const std::string& questId) {
|
||||||
bool changed = setStatus(questId, QuestStatus::Completed);
|
QuestMutationResult result;
|
||||||
if (changed && onQuestCompleted) onQuestCompleted(questId);
|
result.success = setStatus(questId, QuestStatus::Completed);
|
||||||
return changed;
|
if (result.success)
|
||||||
|
result.events.push_back({QuestEventType::Completed, questId, {}});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QuestJournal::failQuest(const std::string& questId) {
|
QuestMutationResult QuestJournal::failQuest(const std::string& questId) {
|
||||||
bool changed = setStatus(questId, QuestStatus::Failed);
|
QuestMutationResult result;
|
||||||
if (changed && onQuestFailed) onQuestFailed(questId);
|
result.success = setStatus(questId, QuestStatus::Failed);
|
||||||
return changed;
|
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);
|
QuestState* quest = findQuest(questId);
|
||||||
if (!quest) return false;
|
if (!quest) return result;
|
||||||
|
|
||||||
for (auto& objective : quest->definition.objectives) {
|
for (auto& objective : quest->definition.objectives) {
|
||||||
if (objective.id == objectiveId) {
|
if (objective.id == objectiveId) {
|
||||||
objective.completed = completed;
|
objective.completed = completed;
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
if (completed) {
|
if (completed) {
|
||||||
if (onObjectiveCompleted) onObjectiveCompleted(questId, objectiveId);
|
result.events.push_back(
|
||||||
|
{QuestEventType::ObjectiveCompleted, questId, objectiveId});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completed && quest->definition.autoComplete && quest->status == QuestStatus::Available) {
|
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; });
|
[](const QuestObjective& o) { return o.completed; });
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
quest->status = QuestStatus::Completed;
|
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);
|
QuestState* quest = findQuest(questId);
|
||||||
if (!quest) return false;
|
if (!quest) return result;
|
||||||
|
|
||||||
for (auto& objective : quest->definition.objectives) {
|
for (auto& objective : quest->definition.objectives) {
|
||||||
if (objective.id == objectiveId) {
|
if (objective.id == objectiveId) {
|
||||||
objective.visible = visible;
|
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);
|
QuestState* quest = findQuest(questId);
|
||||||
if (!quest) return false;
|
if (!quest) return result;
|
||||||
|
|
||||||
if (objectiveIndex < 0 || objectiveIndex >= static_cast<int>(quest->definition.objectives.size())) {
|
if (objectiveIndex < 0 || objectiveIndex >= static_cast<int>(quest->definition.objectives.size())) {
|
||||||
return false;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
quest->activeObjectiveIndex = objectiveIndex;
|
quest->activeObjectiveIndex = objectiveIndex;
|
||||||
return true;
|
result.success = true;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
QuestState* QuestJournal::findQuest(const std::string& questId) {
|
QuestState* QuestJournal::findQuest(const std::string& questId) {
|
||||||
|
|||||||
+24
-13
@@ -6,10 +6,27 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
namespace FRG::Quest {
|
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 {
|
class QuestJournal : public FRG::ISaveable {
|
||||||
public:
|
public:
|
||||||
bool loadFromFile(const std::string& path, const std::string& zipFile = "");
|
bool loadFromFile(const std::string& path, const std::string& zipFile = "");
|
||||||
@@ -22,19 +39,13 @@ public:
|
|||||||
void save(nlohmann::json& out) const override;
|
void save(nlohmann::json& out) const override;
|
||||||
void load(const nlohmann::json& in) override;
|
void load(const nlohmann::json& in) override;
|
||||||
|
|
||||||
// Event callbacks — set by the owner (MenuManager) to receive notifications.
|
QuestMutationResult unlockQuest(const std::string& questId);
|
||||||
std::function<void(const std::string& questId)> onQuestUnlocked;
|
QuestMutationResult completeQuest(const std::string& questId);
|
||||||
std::function<void(const std::string& questId)> onQuestCompleted;
|
QuestMutationResult failQuest(const std::string& questId);
|
||||||
std::function<void(const std::string& questId)> onQuestFailed;
|
|
||||||
std::function<void(const std::string& questId, const std::string& objectiveId)> onObjectiveCompleted;
|
|
||||||
|
|
||||||
bool unlockQuest(const std::string& questId);
|
QuestMutationResult setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true);
|
||||||
bool completeQuest(const std::string& questId);
|
QuestMutationResult setObjectiveVisible(const std::string& questId, const std::string& objectiveId, bool visible = true);
|
||||||
bool failQuest(const std::string& questId);
|
QuestMutationResult setActiveObjective(const std::string& questId, int objectiveIndex);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
QuestState* findQuest(const std::string& questId);
|
QuestState* findQuest(const std::string& questId);
|
||||||
const QuestState* findQuest(const std::string& questId) const;
|
const QuestState* findQuest(const std::string& questId) const;
|
||||||
|
|||||||
@@ -100,13 +100,15 @@ namespace FRG {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto state = player.stateSnapshot();
|
||||||
|
if (!state) return;
|
||||||
std::vector<Eigen::Vector3f> route;
|
std::vector<Eigen::Vector3f> route;
|
||||||
route.reserve(player.state.pathWaypoints.size() + 1);
|
route.reserve(state->pathWaypoints.size() + 1);
|
||||||
route.push_back(Eigen::Vector3f(player.state.position.x(), 0.0f, player.state.position.z()));
|
route.push_back(Eigen::Vector3f(state->position.x(), 0.0f, state->position.z()));
|
||||||
const size_t firstWaypoint = (std::min)(
|
const size_t firstWaypoint = (std::min)(
|
||||||
player.state.currentWaypointIndex, player.state.pathWaypoints.size());
|
state->currentWaypointIndex, state->pathWaypoints.size());
|
||||||
for (size_t i = firstWaypoint; i < player.state.pathWaypoints.size(); ++i) {
|
for (size_t i = firstWaypoint; i < state->pathWaypoints.size(); ++i) {
|
||||||
const Eigen::Vector3f& waypoint = player.state.pathWaypoints[i];
|
const Eigen::Vector3f& waypoint = state->pathWaypoints[i];
|
||||||
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
|
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ namespace FRG {
|
|||||||
constexpr float kPlayerClearanceMs = 20.0f;
|
constexpr float kPlayerClearanceMs = 20.0f;
|
||||||
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
|
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
|
||||||
const float playerSpeed = (std::max)(
|
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 playerRouteTimeMs = totalLength / playerSpeed * 1000.0f;
|
||||||
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
|
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
|
||||||
const float waveSpeed = totalLength / (revealDurationMs / 1000.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