Major refactoring - internal server state machine implemented

This commit is contained in:
Vladislav Khorev committed 2026-10-29 00:09:16 +06:00
1 parent 71c654e5b6
commit 66e93d46f4
48 files changed
+3948 -3696

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
# Game State Architecture
## Purpose
The game now separates authoritative gameplay state from rendering and runtime resources. The current implementation is an in-process server, but public consumers use a snapshot-and-command API designed to support a future network client.
This refactor does **not** add multiplayer networking. It establishes the state ownership and API boundaries needed before networking, replication, multiple local player representations, or server-side synchronization are implemented.
## Architecture Overview
The architecture has four 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. Runtime objects such as `Location`, `Character`, and `InteractiveObject` hold rendering resources and non-owning bindings to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands.
4. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`.
```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`.
### 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.
`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 `LocationState`.
- 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.
State objects use `shared_ptr` internally to keep their addresses stable while local runtime objects are bound to them. 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 or polling the 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.
- 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.
A future network implementation can serialize equivalent requests instead of mutating local state.
## Internal Server
`InternalGameServer` is the current `IGameServer` implementation. It:
- Exclusively owns the aggregate `GameState`.
- Privately stores loaded runtime `Location` objects.
- Executes typed commands against authoritative state.
- Advances the active location simulation through `update(deltaMs)`.
- Synchronizes non-bound auxiliary state such as dialogue, Lua globals, triggers, and teleports.
- Builds and caches immutable snapshots.
- Delegates save/load serialization to the aggregate state model.
`Game` remains a privileged host during this migration. It can access private
internal-server operations for campaign loading, location construction, callback
wiring, and persistence restoration. These host operations are not exposed by
`IGameServer`.
### Animation and combat
Logical animation frame clocks live in `CharacterState`. Animation frame counts are
copied from the loaded clip metadata during runtime construction, after which state
transitions and frame-19 combat impacts are deterministic authoritative simulation.
`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. 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 `LocationState` 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
`Location`, `Character`, and `InteractiveObject` no longer own their authoritative state objects.
- `Location` keeps a private non-owning reference to its `LocationState` and a stable location ID.
- `Character` keeps a private non-owning **const** reference named `characterState`, plus stable entity and location IDs.
- `InteractiveObject` keeps a private non-owning reference to its `InteractiveObjectState`; its configured object name is its stable ID.
The bound state objects are owned by `GameState` or `LocationGameState`. The internal
server advances them directly before runtime presentation. Rendering reads the bound
state; runtime input, scripts, and collision handling submit semantic commands that
invoke `CharacterState` operations against the authoritative `LocationState`.
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. Its only state-facing setup responsibility is reporting frame ranges from
loaded animation assets through `SetCharacterAnimationMetadataCommand`; otherwise 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.
`GameObjectLoader` now:
1. Loads static creation data from location configuration.
2. Creates or reuses server-owned state objects.
3. Applies saved runtime fields over the configuration defaults.
4. Constructs visual objects bound to those state objects.
Configuration remains the source of visual assets, animation paths, meshes, textures, and fixed tuning data.
## State Update Flow
The normal frame flow is:
1. SDL input is processed.
2. UI code submits semantic commands through `IGameServer`.
3. The internal server freezes location character observations and calls
`CharacterState::update()` for the player and each NPC.
4. Attack impacts are queued, then damage is applied after all characters advance.
5. The runtime location consumes value-only events. Navigation requests are sent
back as semantic destination commands, whose server handler calls
`CharacterState::setTarget()` with the authoritative `LocationState`; arrival
function names are resolved through `ScriptEngine`.
6. Runtime characters apply the authoritative animation frame to loaded models and
play audio, particles, and presentation callbacks.
7. Auxiliary dialogue, Lua, trigger, and teleport state is captured into `LocationGameState`.
8. The server increments its revision and invalidates the cached snapshot.
9. Rendering reads the bound local state; UI and external-style consumers read an immutable snapshot.
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.
- A campaign-level player state is created under the `"player"` ID.
- The visual player is created and bound to that state.
### Location loading
When a location is loaded:
- A `LocationGameState` record is created or reused.
- Static configuration is loaded.
- NPC and interactive-object visuals are created against state owned by the location record.
- The shared campaign player is attached.
- Dialogue, Lua, navigation, trigger, teleport, and presentation callbacks are initialized.
- The runtime `Location` is registered privately with `InternalGameServer`.
### 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
Persistence uses one strict, versioned schema. `schemaVersion` is required and
unsupported versions are rejected rather than migrated.
- 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, so
runtime render bindings keep stable addresses.
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 bindings submit semantic
commands through `Location`; the server invokes `CharacterState` operations on the
centrally owned state. NPC, interactive-object, trigger-zone, and teleport-zone
bindings address entities by configured ID. Object, inventory, quest, 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 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 systems remain part of `GameState` for single-player compatibility. UI access now crosses the server interface. They can remain empty or disabled in multiplayer mode as planned.
## Multiplayer Readiness
The refactor provides:
- One authoritative aggregate state owner.
- Multiple player slots keyed by stable ID.
- Stable location, character, object, and zone identities.
- Immutable state snapshots suitable for replication.
- Semantic commands suitable for request serialization.
- A replaceable public `IGameServer` contract.
- A single active-location model matching the initial multiplayer scope.
Still required for real multiplayer:
- Network transport and command serialization.
- Authentication and player/session assignment.
- Server tick scheduling independent of rendering.
- Snapshot delta generation or other replication strategy.
- Latency handling, interpolation, reconciliation, and prediction.
- Command validation, authorization, and anti-cheat rules.
- Multiplayer spawning/despawning and location membership.
- Presentation events delivered over the network.
The current local implementation intentionally preserves synchronous Lua and animation behavior. A remote implementation must define which of those behaviors remain server-authoritative and which become client presentation.
## 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 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.
- `Game` currently uses privileged internal-server APIs for local content construction. A remote client implementation will need a client-side runtime-location factory driven by configuration and snapshots.
## Main Files
- `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence.
- `src/LocationGameState.h` / `src/LocationGameState.cpp` — typed location state, simulation, and ID-keyed persistence.
- `src/game_server/IGameServer.h` — public snapshot and command contract.
- `src/game_server/InternalGameServer.h` / `.cpp` — in-process authoritative implementation.
- `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 bound to
a const authoritative `CharacterState`.
- `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` — configuration/state overlay and runtime construction.
- `src/MenuManager.h` / `src/MenuManager.cpp` — snapshot-driven UI and command submission.
+5
View File
@@ -96,6 +96,8 @@ add_library(main SHARED
../../../../src/Location.cpp
../../../../src/LocationState.h
../../../../src/LocationState.cpp
../../../../src/LocationGameState.h
../../../../src/LocationGameState.cpp
../../../../src/LocationEditor.h
../../../../src/LocationEditor.cpp
../../../../src/NpcCar.h
@@ -104,6 +106,9 @@ add_library(main SHARED
../../../../src/GameConstants.cpp
../../../../src/GameState.h
../../../../src/GameState.cpp
../../../../src/game_server/IGameServer.h
../../../../src/game_server/InternalGameServer.h
../../../../src/game_server/InternalGameServer.cpp
../../../../src/ScriptEngine.h
../../../../src/ScriptEngine.cpp
../../../../src/navigation/PathFinder.h
+5
View File
@@ -121,6 +121,8 @@ add_executable(ShadowOverBishkekDemo
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -129,6 +131,9 @@ add_executable(ShadowOverBishkekDemo
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/game_server/IGameServer.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
+5
View File
@@ -115,6 +115,8 @@ set(SOURCES
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -123,6 +125,9 @@ set(SOURCES
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/game_server/IGameServer.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
+5
View File
@@ -63,6 +63,8 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/Location.cpp
../src/LocationState.h
../src/LocationState.cpp
../src/LocationGameState.h
../src/LocationGameState.cpp
../src/LocationEditor.h
../src/LocationEditor.cpp
../src/NpcCar.h
@@ -71,6 +73,9 @@ add_executable(ShadowOverBishkekDemo WIN32
../src/GameConstants.cpp
../src/GameState.h
../src/GameState.cpp
../src/game_server/IGameServer.h
../src/game_server/InternalGameServer.h
../src/game_server/InternalGameServer.cpp
../src/ScriptEngine.h
../src/ScriptEngine.cpp
../src/navigation/PathFinder.h
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "WashingMachine002",
"name": "WashingMachine002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -27,6 +28,7 @@
"activateFunction": "on_washing_machine1_click"
},
{
"id": "Dryer002",
"name": "Dryer002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -53,6 +55,7 @@
"activateFunction": "on_dryer1_click"
},
{
"id": "Toilet001",
"name": "Toilet001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -79,6 +82,7 @@
"activateFunction": "on_toilet_click"
},
{
"id": "Sink002",
"name": "Sink002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -105,6 +109,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink003",
"name": "Sink003",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -131,6 +136,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink004",
"name": "Sink004",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -157,6 +163,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Sink005",
"name": "Sink005",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -183,6 +190,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Shower001",
"name": "Shower001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -209,6 +217,7 @@
"activateFunction": "on_shower_click"
},
{
"id": "Freezer001",
"name": "Freezer001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -235,6 +244,7 @@
"activateFunction": "on_freezer_click"
},
{
"id": "Boombox001",
"name": "Boombox001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -261,6 +271,7 @@
"activateFunction": "on_boombox_click"
},
{
"id": "WashingMachine001",
"name": "WashingMachine001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -287,6 +298,7 @@
"activateFunction": "on_washing_machine1_click"
},
{
"id": "Shelf001",
"name": "Shelf001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -313,6 +325,7 @@
"activateFunction": "on_shelf_click"
},
{
"id": "Stove001",
"name": "Stove001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -339,6 +352,7 @@
"activateFunction": "on_stove1_click"
},
{
"id": "Dryer001",
"name": "Dryer001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -365,6 +379,7 @@
"activateFunction": "on_dryer1_click"
},
{
"id": "Sink001",
"name": "Sink001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -391,6 +406,7 @@
"activateFunction": "on_sink1_click"
},
{
"id": "Door_Room_1_1_0_Leaf_001",
"name": "Door_Room_1_1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_1_0_Leaf_001.txt.bin",
@@ -415,6 +431,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_-1_0_Leaf_001",
"name": "Door_Room_1_-1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_-1_0_Leaf_001.txt.bin",
@@ -439,6 +456,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_-1_1_0_Leaf_001",
"name": "Door_Room_-1_1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_1_0_Leaf_001.txt.bin",
@@ -463,6 +481,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_-1_-1_0_Leaf_001",
"name": "Door_Room_-1_-1_0_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_0_Leaf_001.txt.bin",
@@ -487,6 +506,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_1_1_Leaf_001",
"name": "Door_Room_1_1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_1_1_Leaf_001.txt.bin",
@@ -511,6 +531,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Room_1_-1_1_Leaf_001",
"name": "Door_Room_1_-1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_1_-1_1_Leaf_001.txt.bin",
@@ -535,6 +556,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_1_1_2_Leaf_001",
"name": "Door_Utility_1_1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_1_1_2_Leaf_001.txt.bin",
@@ -559,6 +581,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_1_-1_2_Leaf_001",
"name": "Door_Utility_1_-1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_1_-1_2_Leaf_001.txt.bin",
@@ -583,6 +606,7 @@
"activateFunction": "on_locked_door_click"
},
{
"id": "Door_Utility_-1_1_2_Leaf_001",
"name": "Door_Utility_-1_1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_-1_1_2_Leaf_001.txt.bin",
@@ -599,6 +623,7 @@
"interactionRadius": 0.0
},
{
"id": "Door_Utility_-1_-1_2_Leaf_001",
"name": "Door_Utility_-1_-1_2_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Utility_-1_-1_2_Leaf_001.txt.bin",
@@ -615,6 +640,7 @@
"interactionRadius": 0.0
},
{
"id": "Phone001",
"name": "Phone001",
"texturePath": "resources/w/dorm2/phone001_tex001.png",
"meshPath": "resources/w/dorm2/phone001_001.txt.bin",
@@ -640,6 +666,7 @@
"activateFunction": "on_phone_pickup"
},
{
"id": "Journal001",
"name": "Journal001",
"texturePath": "resources/w/dorm2/Book_Cover_tex002.png",
"meshPath": "resources/w/dorm2/journal002_002.txt.bin",
@@ -665,6 +692,7 @@
"activateFunction": "on_journal_pickup"
},
{
"id": "Bed001",
"name": "Bed001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -691,6 +719,7 @@
"activateFunction": "on_bed_sleep"
},
{
"id": "Music001",
"name": "Music001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/dorm2/D_Music_004.txt.bin",
@@ -706,6 +735,7 @@
"activateFunction": ""
},
{
"id": "Door_Bath_-1_1_1_Leaf_002",
"name": "Door_Bath_-1_1_1_Leaf_002",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Bath_-1_1_1_Leaf_002.txt.bin",
@@ -733,6 +763,7 @@
"activateFunction": "on_player_bathroom_click"
},
{
"id": "Door_Bath_-1_-1_1_Leaf_002",
"name": "Door_Bath_-1_-1_1_Leaf_002",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Bath_-1_-1_1_Leaf_002.txt.bin",
@@ -757,6 +788,7 @@
"activateFunction": "on_alik_bathroom_click"
},
{
"id": "Door_Room_-1_1_1_Leaf_001",
"name": "Door_Room_-1_1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_1_1_Leaf_001.txt.bin",
@@ -784,6 +816,7 @@
"activateFunction": "on_player_door_click"
},
{
"id": "Door_Room_-1_-1_1_Leaf_001",
"name": "Door_Room_-1_-1_1_Leaf_001",
"texturePath": "resources/w/dorm2/interior_door_tex003.png",
"meshPath": "resources/w/dorm2/Door_Room_-1_-1_1_Leaf_001.txt.bin",
@@ -811,6 +844,7 @@
"activateFunction": "on_alik_door_click"
},
{
"id": "Room_Cover_Bath_W_N_2_001",
"name": "Room_Cover_Bath_W_N_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Bath_W_N_2_002.txt.bin",
@@ -825,6 +859,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_LivingRoom_W_N_2_001",
"name": "Room_Cover_LivingRoom_W_N_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_N_2_002.txt.bin",
@@ -839,6 +874,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_LivingRoom_W_S_2_001",
"name": "Room_Cover_LivingRoom_W_S_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_LivingRoom_W_S_2_002.txt.bin",
@@ -853,6 +889,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Main_Hall_And_Corridors_002",
"name": "Room_Cover_Main_Hall_And_Corridors_002",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Main_Hall_And_Corridors_002.txt.bin",
@@ -867,6 +904,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Others_001",
"name": "Room_Cover_Others_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_All_002.txt.bin",
@@ -881,6 +919,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Utility_W_N_3_001",
"name": "Room_Cover_Utility_W_N_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_N_3_002.txt.bin",
@@ -895,6 +934,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Utility_W_S_3_001",
"name": "Room_Cover_Utility_W_S_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Room_Cover_Utility_W_S_3_002.txt.bin",
@@ -909,6 +949,7 @@
"interactionRadius": 0.0
},
{
"id": "Roof",
"name": "Roof",
"texturePath": "resources/black.png",
"meshPath": "resources/w/dorm2/Shadow_Roof001_002.txt.bin",
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "Container001",
"name": "Container001",
"texturePath": "resources/w/exterior/derbis001_tex001.png",
"meshPath": "resources/w/exterior/Derbis_001.txt.bin",
@@ -25,6 +26,7 @@
"activateFunction": "on_container_clicked"
},
{
"id": "car001",
"name": "car001",
"texturePath": "resources/e/car4096blue_v002.png",
"meshPath": "resources/e/car004.txt.bin",
@@ -49,6 +51,7 @@
"scale": 0.7
},
{
"id": "car001wheels",
"name": "car001wheels",
"texturePath": "resources/w/exterior/Wheel_base003.png",
"meshPath": "resources/w/exterior/wheels002.txt.bin",
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "Bookshelf002",
"name": "Bookshelf002",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -27,6 +28,7 @@
"castShadow": false
},
{
"id": "Bookshelf003",
"name": "Bookshelf003",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -53,6 +55,7 @@
"castShadow": false
},
{
"id": "Bookshelf004",
"name": "Bookshelf004",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -79,6 +82,7 @@
"castShadow": false
},
{
"id": "Bookshelf005",
"name": "Bookshelf005",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -105,6 +109,7 @@
"castShadow": false
},
{
"id": "Cake001",
"name": "Cake001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/interior/D_Cake_005.txt.bin",
@@ -131,6 +136,7 @@
"castShadow": false
},
{
"id": "Computer001",
"name": "Computer001",
"texturePath": "resources/w/interior/computer_texture002.png",
"textureDarkandsPath": "resources/w/interior/computer_texture002_dark.png",
@@ -155,6 +161,7 @@
"activateFunction": "on_computer_clicked"
},
{
"id": "Knife001",
"name": "Knife001",
"texturePath": "resources/w/gg/Props.png",
"meshPath": "resources/w/interior/Knife_lay001_004.txt.bin",
@@ -177,6 +184,7 @@
"castShadowNight" : false
},
{
"id": "Book001",
"name": "Book001",
"texturePath": "resources/w/interior/Book_Cover_tex003.png",
"meshPath": "resources/w/interior/book002_002.txt.bin",
@@ -202,6 +210,7 @@
"castShadowNight" : false
},
{
"id": "Bookshelf001",
"name": "Bookshelf001",
"texturePath": "resources/transparent.png",
"meshPath": "resources/w/Cube_001.txt.bin",
@@ -228,6 +237,7 @@
"castShadow": false
},
{
"id": "ReportCard001",
"name": "ReportCard001",
"texturePath": "resources/w/exterior/texAtlas002.png",
"meshPath": "resources/w/interior/D_report_cardGG_005.txt.bin",
@@ -252,6 +262,7 @@
"activateFunction": "on_report_card_pickup"
},
{
"id": "Note001",
"name": "Note001",
"texturePath": "resources/w/interior/note001_tex003.png",
"meshPath": "resources/w/interior/note001_002.txt.bin",
@@ -276,6 +287,7 @@
"activateFunction": "on_note_pickup"
},
{
"id": "car001",
"name": "car001",
"texturePath": "resources/e/car4096blue_v002.png",
"meshPath": "resources/e/car004.txt.bin",
@@ -299,6 +311,7 @@
"scale": 0.7
},
{
"id": "car001wheels",
"name": "car001wheels",
"texturePath": "resources/w/exterior/Wheel_base003.png",
"meshPath": "resources/w/exterior/wheels002.txt.bin",
@@ -322,6 +335,7 @@
"scale": 0.7
},
{
"id": "Room_N_2_Leaf001",
"name": "Room_N_2_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_N_2_Leaf001.txt.bin",
@@ -349,6 +363,7 @@
"activateFunction": "on_library_door_click"
},
{
"id": "Room_N_1_Leaf001",
"name": "Room_N_1_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_N_1_Leaf001.txt.bin",
@@ -376,6 +391,7 @@
"activateFunction": "on_n2_door_click"
},
{
"id": "Room_S_1_Leaf001",
"name": "Room_S_1_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_1_Leaf001.txt.bin",
@@ -403,6 +419,7 @@
"activateFunction": "on_s2_door_click"
},
{
"id": "Room_S_2_Leaf001",
"name": "Room_S_2_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_2_Leaf001.txt.bin",
@@ -430,6 +447,7 @@
"activateFunction": "on_teachers_door_click"
},
{
"id": "Hall_Leaf001",
"name": "Hall_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputHall_Leaf001.txt.bin",
@@ -457,6 +475,7 @@
"activateFunction": "on_hall_door_click"
},
{
"id": "Room_S_0_Leaf001",
"name": "Room_S_0_Leaf001",
"texturePath": "resources/w/interior/uni_interior_door_tex001.png",
"meshPath": "resources/w/interior/outputRoom_S_0_Leaf001.txt.bin",
@@ -484,6 +503,7 @@
"activateFunction": "on_s1_door_click"
},
{
"id": "Room_Cover_Corridor_001",
"name": "Room_Cover_Corridor_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_Corridor_003.txt.bin",
@@ -501,6 +521,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_Main_Hall_001",
"name": "Room_Cover_Main_Hall_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_Main_Hall_003.txt.bin",
@@ -518,6 +539,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_1_001",
"name": "Room_Cover_South_1_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_1_003.txt.bin",
@@ -535,6 +557,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_North_2_001",
"name": "Room_Cover_North_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_North_2_003.txt.bin",
@@ -552,6 +575,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_2_001",
"name": "Room_Cover_South_2_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_2_003.txt.bin",
@@ -569,6 +593,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_North_3_001",
"name": "Room_Cover_North_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_North_3_003.txt.bin",
@@ -586,6 +611,7 @@
"interactionRadius": 0.0
},
{
"id": "Room_Cover_South_3_001",
"name": "Room_Cover_South_3_001",
"texturePath": "resources/black.png",
"meshPath": "resources/w/interior/Room_Cover_South_3_003.txt.bin",
+1 -1
View File
@@ -145,7 +145,7 @@
"canAttack": true
},
{
"id": "npc_02_woman",
"id": "npc_altynai",
"name": "Алтынай",
"animationIdlePath": "resources/w/girl/girl_idle001_small.anim",
"animationWalkPath": "resources/w/girl/girl_walk010_small.anim",
File diff suppressed because it is too large. Load diff
+151 -70
View File
@@ -15,7 +15,7 @@
true,
true
],
"currentLocationName": "uni_exterior",
"currentLocationId": "",
"globalFloats": {
"player_hp": 200.0
},
@@ -28,98 +28,179 @@
"isNight": false,
"locations": {
"uni_exterior": {
"dialogueState": {
"dialogue": {
"active": false
},
"interactiveObjectStates": [],
"locationState": {
"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
},
"intBin_Plastic": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 1.399999976158142,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"intBin_Waste_A": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 2.200000047683716,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"intBin_Waste_B": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 3.0,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
"scale": 1.0
},
"intRoof": {
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 3.1999998092651367,
"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,
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002,
"isDarklands": false,
"isDawn": false,
"isNight": false,
"targetInteractNpcIndex": -1,
"targetInteractiveObjectIndex": -1,
"targetTeleportZoneIndex": -1,
"targetInteractNpcId": "",
"targetInteractiveObjectId": "",
"targetTeleportZoneId": "",
"taxi": {
"taxiArrivedCallbackCalled": false,
"taxiCar": {
"arrivedCallbackCalled": false,
"car": {
"currentWaypoint": 0,
"mode": 1,
"positionX": -100.0,
"positionY": -100.0,
"positionZ": -100.0,
"position": [
-100.0,
-100.0,
-100.0
],
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypointReachRadius": 3.0,
"waypoints": []
},
"taxiCarInitialState": {
"currentWaypoint": 0,
"mode": 1,
"positionX": -100.0,
"positionY": -100.0,
"positionZ": -100.0,
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypointReachRadius": 3.0,
"waypoints": []
},
"taxiCarTriggerPositionX": 0.0,
"taxiCarTriggerPositionY": 0.0,
"taxiCarTriggerPositionZ": 0.0,
"taxiDefaultWaypoints": []
"waypoints": [],
"wheelAngle": 0.0
}
}
},
"npcStates": [],
"playerState": {
"attackTargetIndex": -1,
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetIndex": -1,
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [],
"position": [
-6, 0, 12.8
],
"requestedWalkTarget": [
-6, 0, 12.8
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6, 0, 12.8
]
},
"scriptGlobals": null,
"teleportZoneState": {},
"triggerZoneState": {}
"teleportZones": {},
"triggerZones": {}
}
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"player": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.025001525878906,
0.0,
12.775001525878906
]
],
"position": [
-6.0,
0.0,
12.800000190734863
],
"requestedWalkTarget": [
-6.0,
0.0,
12.800000190734863
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.025001525878906,
0.0,
12.775001525878906
]
}
},
"questJournal": {
"quests": []
},
"savedAt": "2026-07-24 23:40",
"savedAt": "2026-10-28 23:24",
"schemaVersion": 1,
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false,
"tutorialPhoneChatScreenOpened": false,
"tutorialStep": 9
"tutorialStep": 0,
"tutorialTaxiHintDone": false
}
+4
View File
@@ -3,6 +3,9 @@
-- uni_exterior level with static objects and navigation only.
-- ============================================
function callback_init_location()
end
function callback_enter_location()
end
@@ -10,6 +13,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
+26 -27
View File
@@ -2,18 +2,8 @@
-- NPC PATROL WAYPOINTS
-- ============================================
bathroom_door_opened = false
player_door_opened = false
alik_door_opened = false
game_api.start_dialogue("dialog_start001")
phone_picked_up = false
journal_picked_up = false
function on_npc_interact(npc_index)
if npc_index == 0 then
function on_npc_interact(npc_id)
if npc_id == "npc_alik" then
--[[
local player_alik_aware = game_api.getIntValue("alik_aware")
local player_container_aware = game_api.getIntValue("player_container_aware")
@@ -25,7 +15,7 @@ function on_npc_interact(npc_index)
else
game_api.start_dialogue("dialog_alik001")
end]]
game_api.player_stop_and_rotate_to_npc(0)
game_api.player_stop_and_rotate_to_npc("npc_alik")
game_api.start_dialogue("dialog_alik002")
end
end
@@ -119,8 +109,8 @@ end
function callback_deactivate_cover_alik_room()
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
game_api.set_npc_enabled(0, true)
game_api.npc_rotate_to(0, 90)
game_api.set_npc_enabled("npc_alik", true)
game_api.npc_rotate_to("npc_alik", 90)
end
function callback_deactivate_cover_bathroom()
@@ -161,7 +151,7 @@ function closeAlikDoor()
game_api.activate_interactive_object("Room_Cover_LivingRoom_W_S_2_001")
game_api.set_object_alpha("Room_Cover_LivingRoom_W_S_2_001", 1)
game_api.set_object_rotation("Door_Room_-1_-1_1_Leaf_001", 0)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled("npc_alik", false)
if (bathroom_door_opened) then
game_api.switch_navigation(3) -- closed only alik (a)
else
@@ -330,11 +320,27 @@ function on_toilet_click()
game_api.player_stop()
end
function callback_init_location()
bathroom_door_opened = false
player_door_opened = false
alik_door_opened = false
phone_picked_up = false
journal_picked_up = false
game_api.start_dialogue("dialog_start001")
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_N_2_001")
game_api.rotate_object("Door_Utility_-1_1_2_Leaf_001", 90, 0.01, nil)
game_api.rotate_object("Door_Utility_-1_-1_2_Leaf_001", -90, 0.01, nil)
game_api.set_npc_enabled("npc_alik", false)
game_api.switch_navigation(0)
print("----callback_init_location----")
end
function callback_enter_location()
print("Enter location dorm")
local hp = game_api.getFloatValue("player_hp")
game_api.set_player_hp(hp)
game_api.set_teleport_active(0, false)
game_api.set_teleport_active("tp_dorm_to_loc2", false)
if (game_api.is_night()) then
closeAlikDoor()
end
@@ -347,25 +353,18 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
function callback_call_taxi()
game_api.set_teleport_active(0, true)
game_api.set_trigger_zone_enabled(0, false)
game_api.set_teleport_active("tp_dorm_to_loc2", true)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
game_api.deactivate_interactive_object("Room_Cover_LivingRoom_W_N_2_001")
game_api.rotate_object("Door_Utility_-1_1_2_Leaf_001", 90, 0.01, nil)
game_api.rotate_object("Door_Utility_-1_-1_2_Leaf_001", -90, 0.01, nil)
game_api.set_call_taxi_callback(
callback_call_taxi
)
game_api.set_npc_enabled(0, false)
game_api.switch_navigation(0)
print("Lua script loaded successfully--!")
+8 -19
View File
@@ -1,16 +1,5 @@
print("Lua script loaded successfully!")
function callback_enter_location()
end
function callback_exit_location()
end
game_api.set_location_callbacks(
callback_enter_location,
callback_exit_location
)
function darklands_exit001_enter_callback()
if game_api.is_darklands() then
print("Lua script start_darklands_transition")
@@ -77,11 +66,14 @@ game_api.set_enter_night_callback(
)
function callback_init_location()
end
function callback_enter_location()
print("Enter location uni exterior")
local hp = game_api.getFloatValue("player_hp")
game_api.set_player_hp(hp)
game_api.set_teleport_active(0, false)
game_api.set_teleport_active("tp_loc2_to_dorm", false)
if (game_api.is_night()) then
game_api.deactivate_interactive_object("car001")
game_api.deactivate_interactive_object("car001wheels")
@@ -100,6 +92,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
@@ -108,10 +101,6 @@ game_api.set_trigger_zone_callbacks("darklands_exit001",
darklands_exit001_enter_callback,
nil
)
game_api.set_trigger_zone_callbacks("darklands_exit002",
darklands_exit001_enter_callback,
nil
)
game_api.set_trigger_zone_callbacks("darklands_exit003",
darklands_exit001_enter_callback,
nil
@@ -129,12 +118,12 @@ game_api.set_trigger_zone_callbacks("darklands_exit005",
function taxi_zone001_enter_callback()
game_api.start_dialogue("dialog_taxi001")
game_api.set_trigger_zone_enabled(0, false)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
function callback_call_taxi()
game_api.set_teleport_active(0, true)
game_api.set_trigger_zone_enabled(0, false)
game_api.set_teleport_active("tp_loc2_to_dorm", true)
game_api.set_trigger_zone_enabled("taxi_zone001", false)
end
game_api.set_call_taxi_callback(
+129 -136
View File
@@ -1,14 +1,6 @@
CONST_MSG_EMPTY = "(пусто)"
CONST_MSG_WHERE = "Бекзат, ты где? Я жду..."
hall_door_opened = false
teacher_door_opened = false
player_hold_book = false
teacher_arrived_to_library = false
teacher_told_about_book = false
--[[
0 - day0
1 - darklands
@@ -17,14 +9,6 @@ teacher_told_about_book = false
4 - day
]]
was_state_when_player_left = 0
was_day_when_player_left = 0
morning_can_open_door_index = 0
morning_did_open_door_index = 0
ghost_gone = false
function dialog_aiperi_give_key()
local player_hold_key = game_api.getIntValue("player_hold_key")
@@ -65,7 +49,7 @@ function knife_dialog_zone001_enter_callback()
if (player_hold_knife == 0) then
local lection_is_over = game_api.getIntValue("lection_is_over")
if lection_is_over == 1 then
game_api.npc_stop_and_rotate_to_player(1)
game_api.npc_stop_and_rotate_to_player("npc_02_woman")
if (player_hold_key == 1) then
game_api.start_dialogue("knife_dialog002")
else
@@ -79,7 +63,7 @@ function knife_dialog_zone001_enter_callback()
if (aiperi_talked_after_knife == 0) then
game_api.start_dialogue("knife_dialog_second001")
end
game_api.set_trigger_zone_enabled(1, false)
game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
end
end
end
@@ -110,11 +94,11 @@ game_api.set_trigger_zone_callbacks("knife_dialog_zone001",
function on_knife_pickup()
game_api.pickup_item("knife")
game_api.deactivate_interactive_object("Knife001")
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.setIntValue("player_hold_knife", 1)
game_api.set_trigger_zone_enabled(2, true)
game_api.npc_walk_to(0, -4.57412, 0, 6.78495, on_teacher_arrived2)
game_api.set_trigger_zone_enabled("teacher_dialog_zone001", true)
game_api.npc_walk_to("npc_teacher01", -4.57412, 0, 6.78495, on_teacher_arrived2)
game_api.quest_set_objective_completed("aiperi_knife", "aiperi_knife_take")
game_api.player_stop()
end
@@ -133,14 +117,14 @@ function on_book_pickup()
player_hold_book = true
game_api.quest_set_objective_completed("study_project", "study_project_book")
game_api.start_dialogue("book_dialog003")
--game_api.set_trigger_zone_enabled(3, true)
--game_api.set_trigger_zone_enabled("book_dialog_zone001", true)
end
else
game_api.remove_item("book")
game_api.activate_interactive_object("Book001")
player_hold_book = false
game_api.start_dialogue("book_dialog004")
--game_api.set_trigger_zone_enabled(3, false)
--game_api.set_trigger_zone_enabled("book_dialog_zone001", false)
end
else
print("on_book_pickup step2")
@@ -218,14 +202,14 @@ function on_cake_clicked()
game_api.player_stop()
end
function on_npc_interact(npc_index)
function on_npc_interact(npc_id)
local player_ghost_aware = game_api.getIntValue("ghost_aware")
local player_hold_key = game_api.getIntValue("player_hold_key")
local day = game_api.getIntValue("day")
print("[Lua] NPC interaction! Index: " .. tostring(npc_index))
if npc_index == 1 then
game_api.player_stop_and_rotate_to_npc(1)
print("[Lua] NPC interaction! ID: " .. tostring(npc_id))
if npc_id == "npc_02_woman" then
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
if (game_api.is_dawn()) or (day > 0) then
game_api.start_dialogue("dialog_aiperi_morning001")
else
@@ -236,8 +220,8 @@ function on_npc_interact(npc_index)
end
end
end
if npc_index == 0 then
game_api.player_stop_and_rotate_to_npc(0)
if npc_id == "npc_teacher01" then
game_api.player_stop_and_rotate_to_npc("npc_teacher01")
if (day == 0) then
if (teacher_arrived_to_library) then
if (teacher_told_about_book) then
@@ -264,8 +248,8 @@ function on_npc_interact(npc_index)
end
end
end
if npc_index == 2 then
game_api.player_stop_and_rotate_to_npc(2)
if npc_id == "npc_04_ghost" then
game_api.player_stop_and_rotate_to_npc("npc_04_ghost")
local report_card_signed = game_api.getIntValue("report_card_signed")
if (report_card_signed == 1) then
game_api.start_dialogue("dialog_with_ghost003")
@@ -273,18 +257,18 @@ function on_npc_interact(npc_index)
game_api.start_dialogue("dialog_with_ghost001")
end
end
if npc_index == 6 then
game_api.player_stop_and_rotate_to_npc(6)
if npc_id == "npc_altynai" then
game_api.player_stop_and_rotate_to_npc("npc_altynai")
game_api.start_dialogue("dialog_female_student002")
end
end
function on_quest_over()
ghost_gone = true
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.quest_set_objective_completed("ghost_release", "ghost_release_show")
game_api.start_cutscene("game_complete_cutscene001")
@@ -297,7 +281,7 @@ end
function teacher_zone001_enter_callback()
game_api.start_dialogue("teacher_dialog002")
game_api.set_trigger_zone_enabled(2, false)
game_api.set_trigger_zone_enabled("teacher_dialog_zone001", false)
teacher_told_about_book = true
game_api.player_stop()
end
@@ -474,9 +458,9 @@ function on_hall_door_click()
game_api.switch_navigation(1)
game_api.rotate_object("Hall_Leaf001", 90, 0.5, nil)
game_api.fade_object("Room_Cover_Main_Hall_001", 0, 0.5, callback_deactivate_cover_main_hall)
game_api.set_npc_enabled(0, true)
game_api.set_npc_enabled(6, true)
game_api.npc_rotate_to(6, 180)
game_api.set_npc_enabled("npc_teacher01", true)
game_api.set_npc_enabled("npc_altynai", true)
game_api.npc_rotate_to("npc_altynai", 180)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
game_api.player_stop()
end
@@ -558,22 +542,22 @@ function on_n2_door_click()
end
function on_teacher_arrived()
game_api.npc_rotate_to(0, 180)
game_api.npc_rotate_to("npc_teacher01", 180)
teacher_arrived_to_library = true
end
function on_student_arrived()
game_api.npc_rotate_to(6, 270)
game_api.npc_rotate_to("npc_altynai", 270)
end
function on_teacher_arrived2()
game_api.npc_rotate_to(0, 0)
game_api.npc_rotate_to("npc_teacher01", 0)
end
function book_dialog_zone001_enter_callback()
if (game_api.is_darklands()) then
game_api.start_dialogue("ghost_dialog002")
game_api.set_trigger_zone_enabled(3, false)
game_api.set_trigger_zone_enabled("book_dialog_zone001", false)
game_api.advance_darklands_hud()
else
if (player_hold_book) and (game_api.is_night() == false) then
@@ -621,9 +605,9 @@ function on_sleep_cutscene()
print("Cutscene 2 done!")
game_api.set_night()
--game_api.set_trigger_zone_enabled(1, false)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(6, false)
--game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.switch_navigation(16)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Hall_Leaf001", 0)
@@ -649,14 +633,14 @@ function on_teacher_arrived_intermediate()
game_api.rotate_object("Room_N_2_Leaf001", -90, 0.5, nil)
game_api.fade_object("Room_Cover_North_3_001", 0, 0.5, callback_deactivate_cover_n3)
game_api.switch_navigation(2)
game_api.npc_walk_to(0, 4.94194, 0, 6.64111, on_teacher_arrived)
game_api.npc_walk_to("npc_teacher01", 4.94194, 0, 6.64111, on_teacher_arrived)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
end
function on_lection_cutscene_fadein_callback()
print("Lection cutscene in progress!")
-- -0.0234149 0 10.1139
game_api.npc_set_position(0, 0.0, 0.0, 10.1139)
game_api.npc_set_position("npc_teacher01", 0.0, 0.0, 10.1139)
--4.91002 0 10.375
--game_api.player_set_position(-2.41002, 0, 10.375)
@@ -670,10 +654,10 @@ function callback_on_lection_cutscene_over()
print("Cutscene done!")
game_api.setIntValue("lection_is_over", 1)
game_api.set_npc_enabled(1, true)
game_api.npc_rotate_to(1, 180)
game_api.npc_walk_to(0, 0.758123, 0, 6.50063, on_teacher_arrived_intermediate)
game_api.npc_walk_to(6, 5.28998, 0, -5.525, on_student_arrived)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_rotate_to("npc_02_woman", 180)
game_api.npc_walk_to("npc_teacher01", 0.758123, 0, 6.50063, on_teacher_arrived_intermediate)
game_api.npc_walk_to("npc_altynai", 5.28998, 0, -5.525, on_student_arrived)
local aiperi_knife_aware = game_api.getIntValue("aiperi_knife_aware")
@@ -715,38 +699,36 @@ function on_bookshelf_teacher_clicked()
on_report_card_pickup()
end
first_time_darklands = true
function callback_enter_darklands()
if (first_time_darklands) then
game_api.start_dialogue("ghost_dialog001")
first_time_darklands = false
end
game_api.set_trigger_zone_enabled(1, false)
game_api.set_trigger_zone_enabled("knife_dialog_zone001", false)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_altynai", false)
if (ghost_gone) then
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
else
game_api.set_npc_enabled(2, true)
game_api.set_npc_enabled(3, true)
game_api.set_npc_enabled(4, true)
game_api.set_npc_enabled(5, true)
game_api.set_npc_enabled("npc_04_ghost", true)
game_api.set_npc_enabled("ghost_01x", true)
game_api.set_npc_enabled("ghost_02x", true)
game_api.set_npc_enabled("ghost_03x", true)
game_api.npc_set_hp(3, 35)
game_api.npc_set_hp(4, 35)
game_api.npc_set_hp(5, 35)
game_api.npc_set_hp("ghost_01x", 35)
game_api.npc_set_hp("ghost_02x", 35)
game_api.npc_set_hp("ghost_03x", 35)
game_api.npc_set_position(3, 0, 0, -6.4)
game_api.npc_set_position(4, -5.0, 0, 12)
game_api.npc_set_position(5, 5.0, 0, 12)
game_api.npc_set_position("ghost_01x", 0, 0, -6.4)
game_api.npc_set_position("ghost_02x", -5.0, 0, 12)
game_api.npc_set_position("ghost_03x", 5.0, 0, 12)
end
game_api.switch_navigation(5)
@@ -755,13 +737,13 @@ end
function callback_exit_darklands()
game_api.start_cutscene("darklands_exit001")
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
morning_did_open_door_index = 0
setDay1MorningSetup()
@@ -873,13 +855,13 @@ function setDay0setup()
game_api.deactivate_interactive_object("Note001")
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.activate_interactive_object("car001")
game_api.activate_interactive_object("car001wheels")
@@ -903,7 +885,6 @@ function setDay1setup()
game_api.activate_interactive_object("Room_Cover_South_2_001")
game_api.deactivate_interactive_object("Room_Cover_South_3_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", -90)
game_api.set_object_rotation("Room_N_2_Leaf001", -90)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -911,15 +892,15 @@ function setDay1setup()
game_api.set_object_rotation("Room_S_2_Leaf001", 90)
game_api.set_object_rotation("Hall_Leaf001", 90)
game_api.set_npc_enabled(0, true)
game_api.set_npc_enabled(1, true)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, true)
game_api.set_npc_enabled("npc_teacher01", true)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", true)
game_api.npc_set_position(1, 0.98998, 0, -7.525)
game_api.npc_set_position("npc_02_woman", 0.98998, 0, -7.525)
game_api.deactivate_interactive_object("Cake001")
@@ -939,7 +920,6 @@ function setDay1MorningSetup()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
game_api.activate_interactive_object("Room_Cover_Main_Hall_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", 0)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -964,15 +944,15 @@ function setDay1MorningSetup()
-- TODO: Make check for teacher_door_opened false or true
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.npc_set_position(1, 0.98998, 0, -7.525)
game_api.npc_set_position("npc_02_woman", 0.98998, 0, -7.525)
if morning_did_open_door_index == 1 then
game_api.deactivate_interactive_object("Room_Cover_South_1_001")
@@ -1043,7 +1023,6 @@ function setDay1NightSetup()
game_api.deactivate_interactive_object("Room_Cover_Corridor_001")
game_api.activate_interactive_object("Room_Cover_Main_Hall_001")
game_api.set_object_rotation("Room_N_0_Leaf001", 0)
game_api.set_object_rotation("Room_N_1_Leaf001", 0)
game_api.set_object_rotation("Room_N_2_Leaf001", 0)
game_api.set_object_rotation("Room_S_0_Leaf001", 0)
@@ -1059,13 +1038,13 @@ function setDay1NightSetup()
game_api.set_object_alpha("Room_Cover_Corridor_001", 1)
game_api.set_object_alpha("Room_Cover_Main_Hall_001", 1)
game_api.set_npc_enabled(0, false)
game_api.set_npc_enabled(1, false)
game_api.set_npc_enabled(2, false)
game_api.set_npc_enabled(3, false)
game_api.set_npc_enabled(4, false)
game_api.set_npc_enabled(5, false)
game_api.set_npc_enabled(6, false)
game_api.set_npc_enabled("npc_teacher01", false)
game_api.set_npc_enabled("npc_02_woman", false)
game_api.set_npc_enabled("npc_04_ghost", false)
game_api.set_npc_enabled("ghost_01x", false)
game_api.set_npc_enabled("ghost_02x", false)
game_api.set_npc_enabled("ghost_03x", false)
game_api.set_npc_enabled("npc_altynai", false)
game_api.deactivate_interactive_object("car001")
game_api.deactivate_interactive_object("car001wheels")
@@ -1222,8 +1201,8 @@ game_api.set_chat_callbacks(
)
function callback_aiperi_dialog_morning()
game_api.player_stop_and_rotate_to_npc(1)
game_api.npc_stop_and_rotate_to_player(1)
game_api.player_stop_and_rotate_to_npc("npc_02_woman")
game_api.npc_stop_and_rotate_to_player("npc_02_woman")
game_api.start_dialogue("dialog_aiperi_morning001")
end
@@ -1246,9 +1225,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, 6.5)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 0.0, 0, 9.1, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, 6.5)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 0.0, 0, 9.1, callback_aiperi_dialog_morning)
game_api.player_walk_to(0.0, 0.0, 10.0, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1268,9 +1247,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0.0, 0, 6.43818)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.34349, 0, 6.31229, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0.0, 0, 6.43818)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 2.34349, 0, 6.31229, callback_aiperi_dialog_morning)
game_api.player_walk_to(3.52174, 0, 6.28993, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1290,9 +1269,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, 2.11898, 0, -1.7074, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", 2.11898, 0, -1.7074, callback_aiperi_dialog_morning)
game_api.player_walk_to(3.36055, 0, -2.0345, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1312,9 +1291,9 @@ function on_aiperi_opens_door()
game_api.switch_navigation(9)
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -1.7911)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.454, 0, -1.61871, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -1.7911)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", -2.454, 0, -1.61871, callback_aiperi_dialog_morning)
game_api.player_walk_to(-3.63363, 0, -1.50727, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1334,9 +1313,9 @@ function on_aiperi_opens_door()
end
--Aiperi incoming
game_api.npc_set_position(1, 0, 0, -9.77948)
game_api.set_npc_enabled(1, true)
game_api.npc_walk_to(1, -2.53977, 0, -9.77852, callback_aiperi_dialog_morning)
game_api.npc_set_position("npc_02_woman", 0, 0, -9.77948)
game_api.set_npc_enabled("npc_02_woman", true)
game_api.npc_walk_to("npc_02_woman", -2.53977, 0, -9.77852, callback_aiperi_dialog_morning)
game_api.player_walk_to(-3.64494, 0, -9.66711, nil)
game_api.play_sound("audio/702171__foxfire__door-opening.ogg")
@@ -1345,7 +1324,24 @@ function on_aiperi_opens_door()
end
function on_aiperi_morning_dialog_finished()
game_api.npc_walk_to(1, 0.98998, 0, -7.525, nil)
game_api.npc_walk_to("npc_02_woman", 0.98998, 0, -7.525, nil)
end
function callback_init_location()
hall_door_opened = false
teacher_door_opened = false
player_hold_book = false
teacher_arrived_to_library = false
teacher_told_about_book = false
was_state_when_player_left = 0
was_day_when_player_left = 0
morning_can_open_door_index = 0
morning_did_open_door_index = 0
ghost_gone = false
first_time_darklands = true
setDay0setup()
game_api.switch_navigation(0)
end
function callback_enter_location()
@@ -1437,6 +1433,7 @@ function callback_exit_location()
end
game_api.set_location_callbacks(
callback_init_location,
callback_enter_location,
callback_exit_location
)
@@ -1467,9 +1464,5 @@ end
game_api.set_cutscene_callback("game_complete_cutscene001", callback_game_complete_cutscene)
setDay0setup()
game_api.switch_navigation(0)
--debugAllOpen()
print("Lua script loaded successfully!---!")
@@ -1,6 +1,7 @@
{
"objects": [
{
"id": "intBin_Cans",
"name": "intBin_Cans",
"texturePath": "resources/w/shelter/models/intBin_Cans_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Cans.txt.bin",
@@ -14,6 +15,7 @@
"interactionRadius": 0.0
},
{
"id": "intBin_Plastic",
"name": "intBin_Plastic",
"texturePath": "resources/w/shelter/models/intBin_Plastic_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Plastic.txt.bin",
@@ -27,6 +29,7 @@
"interactionRadius": 0.0
},
{
"id": "intBin_Waste_A",
"name": "intBin_Waste_A",
"texturePath": "resources/w/shelter/models/intBin_Waste_A_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Waste_A.txt.bin",
@@ -40,6 +43,7 @@
"interactionRadius": 0.0
},
{
"id": "intBin_Waste_B",
"name": "intBin_Waste_B",
"texturePath": "resources/w/shelter/models/intBin_Waste_B_atlas.png",
"meshPath": "resources/w/shelter/models/intBin_Waste_B.txt.bin",
@@ -53,6 +57,7 @@
"interactionRadius": 0.0
},
{
"id": "intRoof",
"name": "intRoof",
"texturePath": "resources/w/shelter/models/intRoof_atlas.png",
"meshPath": "resources/w/shelter/models/intRoof.txt",
@@ -66,6 +71,7 @@
"interactionRadius": 0.0
},
{
"id": "intVending.00",
"name": "intVending.00",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
@@ -79,6 +85,7 @@
"interactionRadius": 0.0
},
{
"id": "intVending.01",
"name": "intVending.01",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
@@ -92,6 +99,7 @@
"interactionRadius": 0.0
},
{
"id": "intVending.02",
"name": "intVending.02",
"texturePath": "resources/w/shelter/models/vending_atlas.png",
"meshPath": "resources/w/shelter/models/intVending.txt",
+116 -450
View File
@@ -9,10 +9,11 @@
#include "render/TextureManager.h"
#include "utils/Utils.h"
#include "TextModel.h"
#include "game_server/IGameServer.h"
#include "navigation/PathFinder.h"
#include "Location.h"
namespace FRG {
const float ATTACK_COOLDOWN_TIME = 1.6f;
extern float x;
extern float y;
@@ -26,6 +27,8 @@ void Character::loadAnimation(AnimationState animState, const std::string& filen
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
void Character::loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile) {
@@ -36,11 +39,14 @@ void Character::loadBinaryAnimation(AnimationState animState, const std::string&
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
std::unique_ptr<Character> Character::createFromState(CharacterState st, Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>();
c->state = std::move(st);
std::unique_ptr<Character> Character::createFromState(const CharacterState& st, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>(st, server, locationId, entityId);
const std::string zip = zipFile ? zipFile : "";
@@ -56,7 +62,7 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
for (const auto& [animState, path] : c->state.creationInfo.animationPaths)
for (const auto& [animState, path] : c->characterState.creationInfo.animationPaths)
loadAnim(animState, path);
auto loadTex = [&](const std::string& path) -> std::shared_ptr<Texture> {
@@ -70,27 +76,27 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
if (!c->state.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(c->state.creationInfo.singleTexturePath);
if (!c->characterState.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(c->characterState.creationInfo.singleTexturePath);
if (tex) c->setTexture(tex);
}
for (const auto& [meshName, texPath] : c->state.creationInfo.meshTexturePaths) {
for (const auto& [meshName, texPath] : c->characterState.creationInfo.meshTexturePaths) {
auto tex = loadTex(texPath);
if (tex) c->setTexture(meshName, tex);
}
if (!c->state.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = c->state.creationInfo.weaponMeshPath;
if (!c->characterState.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = c->characterState.creationInfo.weaponMeshPath;
try {
if (wp.size() >= 4 && wp.substr(wp.size() - 4) == ".bin")
c->weaponMesh.data = LoadModelFromBinFile(wp, zip);
else
c->weaponMesh.data = LoadFromTextFile02(wp, zip);
if (c->state.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(c->state.creationInfo.weaponMeshMove);
if (c->state.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(c->state.creationInfo.weaponMeshScale);
if (c->characterState.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(c->characterState.creationInfo.weaponMeshMove);
if (c->characterState.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(c->characterState.creationInfo.weaponMeshScale);
c->weaponMesh.RefreshVBO();
} catch (const std::exception& e) {
@@ -98,100 +104,14 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
}
c->weaponTexture = loadTex(c->state.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = c->state.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = c->state.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = c->state.creationInfo.weaponInitialRotation.toRotationMatrix();
c->weaponTexture = loadTex(c->characterState.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = c->characterState.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = c->characterState.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = c->characterState.creationInfo.weaponInitialRotation.toRotationMatrix();
c->setTarget(c->state.position);
return c;
}
void Character::setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived, std::string callbackName) {
Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
(normalizedTarget - state.requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool alreadyMovingToTarget =
!state.pathWaypoints.empty() || (state.walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool stoppedAfterFailedPath =
state.pathWaypoints.empty() && (state.walkTarget - state.position).norm() <= TARGET_REPLAN_THRESHOLD;
if (!onArrived && sameRequestedTarget && (alreadyMovingToTarget || stoppedAfterFailedPath)) {
return;
}
state.requestedWalkTarget = normalizedTarget;
state.onArrivedCallback = std::move(onArrived);
state.onArrivedCallbackName = std::move(callbackName);
if (pathPlanner) {
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
return;
}
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
state.walkTarget = normalizedTarget;
}
void Character::stopAndRotateToTarget(int npcId)
{
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName = "";
state.walkTarget = state.position;
state.requestedWalkTarget = state.position;
state.faceTargetIndex = npcId;
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
void Character::setPathPlanner(PathPlanner planner) {
pathPlanner = std::move(planner);
}
void Character::resetPlayerAfterDeath() {
if (!state.isPlayer) return;
state.currentState = AnimationState::STAND;
state.resetAnim = true;
state.showWeapon = false;
state.battle_state = 0;
state.attack = 0;
}
void Character::forceReplan() {
if (!pathPlanner) return;
const Eigen::Vector3f normalizedTarget(state.requestedWalkTarget.x(), 0.f, state.requestedWalkTarget.z());
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
}
void Character::setTexture(std::shared_ptr<FRG::Texture> texture) {
for (auto& animEntry : animations) {
for (const auto& name : animEntry.second.model.meshNamesOrdered) {
@@ -205,275 +125,38 @@ void Character::setTexture(const std::string& meshName, std::shared_ptr<Texture>
}
AnimationState Character::resolveActiveState() const {
if (animations.count(state.currentState)) return state.currentState;
if (animations.count(characterState.currentState)) return characterState.currentState;
return AnimationState::STAND;
}
float Character::getSpeedMultiplier(float transitionProgress) const {
if (state.isPlayer) {
return 1.0f + (1.5f - 1.0f) * transitionProgress;
} else {
return 1.0f - (1.0f - 0.1f) * transitionProgress;
}
}
void Character::update(int64_t deltaMs, const CharacterResolver& resolver) {
if (!state.enabled) return;
if (state.slowMoTimeRemaining > 0.0f) {
state.slowMoTimeRemaining -= static_cast<float>(deltaMs) / 1000.0f;
float transitionProgress;
if (state.slowMoTimeRemaining > state.slowMoActiveTime - state.slowMoTransitionTime) {
float timeInTransition = state.slowMoActiveTime - state.slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / state.slowMoTransitionTime);
} else if (state.slowMoTimeRemaining < state.slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, state.slowMoTimeRemaining / state.slowMoTransitionTime);
} else {
transitionProgress = 1.0f;
}
float speedMultiplier = getSpeedMultiplier(transitionProgress);
deltaMs = static_cast<int64_t>(static_cast<float>(deltaMs) * speedMultiplier);
}
Character* attackTarget = resolver ? resolver(state.attackTargetIndex) : nullptr;
Character* faceTarget = resolver ? resolver(state.faceTargetIndex) : nullptr;
Eigen::Vector3f activeTarget;
Eigen::Vector3f lookTarget;
if (attackTarget == nullptr) {
state.battle_state = 0;
state.attack = 0;
}
if (attackTarget != nullptr) {
float distToGhost = (attackTarget->state.position - state.position).norm();
if (distToGhost >= state.visibleRange) {
if (state.isPlayer) {
setTarget(attackTarget->state.position);
}
state.battle_state = 0;
} else if (distToGhost < state.visibleRange && distToGhost >= kAttackRange) {
setTarget(attackTarget->state.position);
state.battle_state = 0;
} else {
state.battle_state = 1;
}
}
if (!state.pathWaypoints.empty() && state.currentWaypointIndex < state.pathWaypoints.size()) {
activeTarget = state.pathWaypoints[state.currentWaypointIndex];
} else {
activeTarget = state.walkTarget;
}
if (state.battle_state == 0) {
lookTarget = activeTarget;
} else {
lookTarget = attackTarget->state.position;
}
if (state.currentState == AnimationState::WALK || state.currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - state.position;
toTarget.y() = 0.f;
float dist = toTarget.norm();
if (dist > CharacterState::kWalkThreshold) {
Eigen::Vector3f dir = toTarget / dist;
float moveAmount = state.walkSpeed * static_cast<float>(deltaMs) / 1000.f;
if (moveAmount >= dist) {
state.position = activeTarget;
state.position.y() = 0.f;
} else {
state.position += dir * moveAmount;
}
state.targetFacingAngle = atan2(dir.x(), -dir.z());
state.currentState = AnimationState::WALK;
} else {
state.currentState = AnimationState::STAND;
const bool hasNextWaypoint = !state.pathWaypoints.empty() &&
state.currentWaypointIndex + 1 < state.pathWaypoints.size();
if (hasNextWaypoint) {
++state.currentWaypointIndex;
} else {
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
if (!hasNextWaypoint && state.onArrivedCallback) {
auto cb = std::move(state.onArrivedCallback);
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
cb();
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->state.position - state.position;
toFace.y() = 0.f;
float d = toFace.norm();
if (d > 1e-3f) {
state.targetFacingAngle = atan2(toFace.x() / d, -toFace.z() / d);
} else {
state.targetFacingAngle = state.facingAngle;
}
}
}
}
if (state.hp <= 0) {
if (state.currentState != AnimationState::ACTION_TO_DEATH &&
state.currentState != AnimationState::DEATH_IDLE) {
state.currentState = AnimationState::ACTION_TO_DEATH;
state.resetAnim = true;
state.attackTargetIndex = CharacterState::kNoTarget;
}
} else {
if (state.battle_state == 1) {
state.targetFacingAngle = atan2(lookTarget.x() - state.position.x(),
-(lookTarget.z() - state.position.z()));
if (state.currentState == AnimationState::STAND || state.currentState == AnimationState::WALK) {
state.currentState = AnimationState::STAND_TO_ACTION;
state.resetAnim = true;
}
if (state.canAttack && state.attack == 0 && state.attack_cooldown < 0.f &&
state.currentState == AnimationState::ACTION_IDLE) {
state.attack = 1;
state.attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (state.canAttack && state.attack_cooldown >= 0.f) {
state.attack_cooldown -= deltaMs / 1000.f;
}
if (state.attack == 1 && state.currentState == AnimationState::ACTION_IDLE) {
if (attackTarget != nullptr && attackTarget->state.hp > 0) {
state.currentState = AnimationState::ACTION_ATTACK;
state.resetAnim = true;
}
}
} else {
if (state.currentState == AnimationState::STAND_TO_ACTION ||
state.currentState == AnimationState::ACTION_IDLE ||
state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_TO_STAND;
state.resetAnim = true;
}
}
}
// Rotate toward target facing angle at constant angular speed
float angleDiff = state.targetFacingAngle - state.facingAngle;
while (angleDiff > static_cast<float>(M_PI)) angleDiff -= 2.f * static_cast<float>(M_PI);
while (angleDiff < -static_cast<float>(M_PI)) angleDiff += 2.f * static_cast<float>(M_PI);
float rotStep = state.rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotStep) {
state.facingAngle = state.targetFacingAngle;
// One-shot: once aligned, clear the face target so the NPC stops tracking.
state.faceTargetIndex = CharacterState::kNoTarget;
} else {
state.facingAngle += (angleDiff > 0.f ? rotStep : -rotStep);
}
if (!state.isPlayer && state.hp > 0.f && state.battle_state == 0 && state.maxHomeDrift > 0.f) {
state.homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (state.homeDriftCheckTimer <= 0.f) {
state.homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = state.homePosition - state.position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > state.maxHomeDrift) {
setTarget(state.homePosition);
}
}
}
void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
if (hitSparkEmitter.isConfigured()) {
hitSparkEmitter.update(static_cast<float>(deltaMs));
}
auto it = animations.find(state.currentState);
if (events) {
if (events->deathAnimationComplete && onDeathAnimComplete)
onDeathAnimComplete();
if (events->attackImpact && audioPlayer) {
audioPlayer->playSoundAsync(characterState.isPlayer
? "resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg"
: "resources/audio/13925__adcbicycle__21.ogg");
}
}
if (!characterState.enabled) return;
auto it = animations.find(characterState.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (state.resetAnim) {
state.resetAnim = false;
anim.currentFrame = 0;
}
int prevFrame = anim.currentFrame;
anim.currentFrame += static_cast<float>(deltaMs) / 24.f;
if (static_cast<int>(anim.currentFrame) >= 20 && state.currentState == AnimationState::STAND_TO_ACTION) {
state.showWeapon = true;
} else if (static_cast<int>(anim.currentFrame) <= 32 && state.currentState == AnimationState::ACTION_TO_STAND) {
state.showWeapon = true;
} else if (state.currentState == AnimationState::ACTION_ATTACK_2 ||
state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_IDLE) {
state.showWeapon = true;
} else {
state.showWeapon = false;
}
auto attackDirectionToTarget = [&]() {
Eigen::Vector3f dir = attackTarget->state.position - state.position;
dir.y() = 0.f;
float n = dir.norm();
if (n > 1e-3f) dir /= n;
else dir = Eigen::Vector3f::Zero();
return dir;
};
if (prevFrame < 19 && static_cast<int>(anim.currentFrame) >= 19 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
if (attackTarget != nullptr) {
attackTarget->applyDamage(10.f, attackDirectionToTarget(), index);
if (audioPlayer) {
if (state.isPlayer)
{
audioPlayer->playSoundAsync("resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg");
}
else
{
audioPlayer->playSoundAsync("resources/audio/13925__adcbicycle__21.ogg");
}
}
}
}
int frms = anim.model.animations[0].keyFrames[anim.model.animations[0].keyFrames.size() - 1].frame;
if (static_cast<int>(anim.currentFrame) >= frms) {
anim.currentFrame = anim.model.startingFrame;
if (state.currentState == AnimationState::STAND_TO_ACTION) {
state.currentState = AnimationState::ACTION_IDLE;
}
if (state.currentState == AnimationState::ACTION_TO_STAND) {
state.currentState = AnimationState::STAND;
}
if (state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_IDLE;
state.attack = 0;
}
if (state.currentState == AnimationState::ACTION_TO_DEATH) {
state.currentState = AnimationState::DEATH_IDLE;
if (state.onDeathAnimComplete) {
state.onDeathAnimComplete();
}
}
}
anim.currentFrame = (std::min)(
characterState.animationFrame, static_cast<float>((std::max)(0, anim.totalFrames - 1)));
if (static_cast<int>(anim.currentFrame) != anim.lastFrame) {
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
static_cast<int>(anim.currentFrame));
if (!state.useGpuSkinning) {
if (!characterState.useGpuSkinning) {
anim.model.Interpolate(static_cast<int>(anim.currentFrame));
}
anim.lastFrame = static_cast<int>(anim.currentFrame);
@@ -488,9 +171,9 @@ void Character::draw(Renderer& renderer) {
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (!state.enabled) return;
if (!characterState.enabled) return;
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawGpuSkinning(renderer);
return;
}
@@ -509,10 +192,10 @@ void Character::draw(Renderer& renderer) {
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -563,10 +246,10 @@ void Character::drawGpuSkinning(Renderer& renderer) {
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(anim.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -610,24 +293,8 @@ bool Character::prepareGpuSkinning() {
return true;
}
void Character::tryAttackCombo()
{
logger() << "Try Attack Combo" << std::endl;
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (static_cast<int>(anim.currentFrame) >= 20 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
logger() << "Try Attack Combo Success" << std::endl;
anim.currentFrame = anim.model.startingFrame;
}
}
void Character::drawAttachedWeapon(Renderer& renderer) {
if (!state.showWeapon) return;
if (!characterState.showWeapon) return;
if (weaponMesh.data.PositionData.size() == 0 || !weaponTexture) return;
auto it = animations.find(resolveActiveState());
@@ -643,10 +310,10 @@ void Character::drawAttachedWeapon(Renderer& renderer) {
Eigen::Matrix4f currentBone = anim.gpuSkinningShaderData.skinningMatrices[boneIdx] * bindBone;
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix(Eigen::Vector3f(currentBone.block<3, 1>(0, 3)));
renderer.RotateMatrix(Eigen::Matrix3f(currentBone.block<3, 3>(0, 0)));
renderer.TranslateMatrix(weaponInitialPosition);
@@ -660,8 +327,8 @@ void Character::drawAttachedWeapon(Renderer& renderer) {
// ==================== Shadow depth pass ====================
void Character::drawShadowDepth(Renderer& renderer) {
if (!state.enabled) return;
if (state.useGpuSkinning) {
if (!characterState.enabled) return;
if (characterState.useGpuSkinning) {
drawShadowDepthGpuSkinning(renderer);
} else {
drawShadowDepthCpu(renderer);
@@ -674,10 +341,10 @@ void Character::drawShadowDepthCpu(Renderer& renderer) {
if (it == animations.end()) return;
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -710,10 +377,10 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
renderer.shaderManager.PushShader(shadowSkinningShader);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
CheckGlError(__FILE__, __LINE__);
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
@@ -740,7 +407,7 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
// ==================== Main pass with shadows ====================
void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -748,7 +415,7 @@ void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightF
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawGpuSkinningWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
} else {
drawCpuWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
@@ -779,10 +446,10 @@ void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lig
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -841,10 +508,10 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
CheckGlError(__FILE__, __LINE__);
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
@@ -887,7 +554,7 @@ void Character::drawNight(Renderer& renderer,
const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor)
{
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -895,7 +562,7 @@ void Character::drawNight(Renderer& renderer,
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawNightGpuSkinning(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor);
} else {
@@ -939,10 +606,10 @@ void Character::drawNightGpuSkinning(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(it->second.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -1010,10 +677,10 @@ void Character::drawNightCpu(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -1045,14 +712,14 @@ void Character::drawNightWithShadow(Renderer& renderer,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor)
{
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawNightGpuSkinningWithShadow(renderer, plPosEye, plDirEye, plColors, plCount,
plWorldXZ, plLimitXZ, cameraViewInverse,
lightFromCamera, shadowMapTex, ambientColor, fogColor);
@@ -1107,10 +774,10 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(it->second.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -1191,10 +858,10 @@ void Character::drawNightCpuWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -1232,16 +899,15 @@ void Character::setupHitSparks(std::shared_ptr<Texture> sparkTexture) {
hitSparkEmitter.markConfigured();
}
void Character::applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection, int attackerIndex) {
logger() << "Character::applyDamage" << std::endl;
state.hp -= damageAmount;
if (state.hp < 0) state.hp = 0;
if (state.onHpChanged) state.onHpChanged(state.hp, state.initialHp, attackerIndex);
void Character::presentDamage(float hp, float maxHp,
const Eigen::Vector3f& attackDirection,
const std::string& attackerId) {
if (onHpChanged)
onHpChanged(hp, maxHp, attackerId);
if (!hitSparkEmitter.isConfigured()) return;
Eigen::Vector3f emitPos = state.position + Eigen::Vector3f(0.f, 1.f, 0.f);
Eigen::Vector3f emitPos = characterState.position + Eigen::Vector3f(0.f, 1.f, 0.f);
hitSparkEmitter.resetEmissionPoints({ emitPos });
hitSparkEmitter.setEmissionDirection(attackDirection);
@@ -1262,12 +928,12 @@ void Character::drawName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
if (state.isPlayer) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
if (state.npcName.empty()) return;
if (characterState.isPlayer) return;
if (!characterState.enabled) return;
if (characterState.hp <= 0.f) return;
if (characterState.npcName.empty()) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f);
Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.83f, characterState.position.z(), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return;
@@ -1278,11 +944,11 @@ void Character::drawName(TextRenderer& textRenderer,
float screenX = (ndcX * 0.5f + 0.5f) * Environment::projectionWidth;
float screenY = (ndcY * 0.5f + 0.5f) * Environment::projectionHeight;
std::array<float, 4> color = state.canAttack
std::array<float, 4> color = characterState.canAttack
? std::array<float, 4>{ 1.f, 0.f, 0.f, 1.f }
: std::array<float, 4>{ 0.f, 1.f, 0.f, 1.f };
const std::string& translatedName = Character::nameTranslations.translateRef(state.npcName, g_currentLanguage);
const std::string& translatedName = Character::nameTranslations.translateRef(characterState.npcName, g_currentLanguage);
textRenderer.drawText(translatedName, screenX, screenY, 1.0f, true, color);
}
@@ -1291,12 +957,12 @@ void Character::drawHealthBar(Renderer& renderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
if (state.isPlayer || !state.canAttack) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
if (state.initialHp <= 0.f) return;
if (characterState.isPlayer || !characterState.canAttack) return;
if (!characterState.enabled) return;
if (characterState.hp <= 0.f) return;
if (characterState.initialHp <= 0.f) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.75f, state.position.z(), 1.f);
Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.75f, characterState.position.z(), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return;
float ndcX = clip.x() / clip.w();
@@ -1314,7 +980,7 @@ void Character::drawHealthBar(Renderer& renderer,
const float bottom = sy - barH * 0.5f;
const float top = sy + barH * 0.5f;
float frac = state.hp / state.initialHp;
float frac = characterState.hp / characterState.initialHp;
if (frac < 0.f) frac = 0.f;
if (frac > 1.f) frac = 1.f;
const float split = left + barW * frac;
+33 -46
View File
@@ -11,30 +11,25 @@
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <utility>
#include "dialogue/TranslationDatabase.h"
#include "AudioPlayerAsync.h"
namespace FRG {
class TextRenderer;
class IGameServer;
class Character {
public:
Character(const CharacterState& boundState, IGameServer& server,
std::string locationId, std::string entityId)
: characterState(boundState), 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); }
static constexpr float kAttackRange = 1.25f;
// Resolves a character index to a live Character pointer.
// kNoTarget (-1) → nullptr, kPlayerIndex (-2) → Location::player, 0..n → npcs[n].
using CharacterResolver = std::function<Character*(int index)>;
using PathPlanner = std::function<std::vector<Eigen::Vector3f>(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end)>;
// All serializable game state (position, hp, flags, pathfinding, etc.)
CharacterState state;
int index = CharacterState::kNoTarget;
// ---- Asset loading ----
void loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
void loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
@@ -46,31 +41,19 @@ public:
// Creates a fully-loaded Character from a CharacterState (including its creationInfo).
// Loads all animations, textures, and weapon mesh/texture from the paths stored in creationInfo.
static std::unique_ptr<Character> createFromState(CharacterState st, Renderer& renderer, const char* zipFile);
static std::unique_ptr<Character> createFromState(
const CharacterState& initialState, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile);
// ---- Per-frame update ----
// resolver maps character indices to live pointers so attackTargetIndex / faceTargetIndex
// can be dereferenced inside update(). Pass nullptr only if no targets are set.
void update(int64_t deltaMs, const CharacterResolver& resolver = nullptr);
void setPathPlanner(PathPlanner planner);
// ---- Navigation (path data lives in state; pathPlanner lives here) ----
// callbackName, if given, is the global Lua function name backing onArrived —
// stored in state.onArrivedCallbackName so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived = nullptr, std::string callbackName = "");
void stopAndRotateToTarget(int npcId);
void forceReplan();
void stopInPlace() { state.stopInPlace(); }
// ---- Reset ----
void resetPlayerAfterDeath();
void tryAttackCombo();
// Applies authoritative animation state to runtime resources and consumes
// presentation-only events emitted by CharacterState::update.
void update(int64_t deltaMs, const CharacterUpdateEvents* events = nullptr);
// ---- Damage ----
void applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection = Eigen::Vector3f::Zero(), int attackerIndex = CharacterState::kNoTarget);
void presentDamage(float hp, float maxHp, const Eigen::Vector3f& attackDirection,
const std::string& attackerId);
// ---- Spark setup ----
void setupHitSparks(std::shared_ptr<Texture> sparkTexture);
@@ -102,13 +85,10 @@ public:
const float* ambientColor, const float* fogColor);
// ---- Query helpers (delegate to state) ----
bool isMoving() const { return state.isMoving(); }
const Eigen::Vector3f& getRequestedWalkTarget() const { return state.getRequestedWalkTarget(); }
Eigen::Vector3f getCurrentNavigationTarget() const { return state.getCurrentNavigationTarget(); }
float getHp() const { return state.getHp(); }
void setHp(float newHp) { state.setHp(newHp); }
float getSpeedMultiplier(float transitionProgress) const;
bool isMoving() const { return characterState.isMoving(); }
const Eigen::Vector3f& getRequestedWalkTarget() const { return characterState.getRequestedWalkTarget(); }
Eigen::Vector3f getCurrentNavigationTarget() const { return characterState.getCurrentNavigationTarget(); }
float getHp() const { return characterState.getHp(); }
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
VertexRenderStruct weaponMesh;
@@ -123,16 +103,23 @@ public:
static Dialogue::TranslationDatabase nameTranslations;
private:
friend class Game;
friend class Location;
friend class ScriptEngine;
friend class LocationEditor;
friend class PathVisualization;
friend class InternalGameServer;
const CharacterState& characterState; // private non-owning binding to server-owned storage
IGameServer& gameServer_;
std::string locationId_;
std::string entityId_;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, const std::string&)> onHpChanged;
std::map<AnimationState, BoneAnimationDataNew> animations;
VertexRenderStruct modelMutable;
VertexRenderStruct healthBarMesh;
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
PathPlanner pathPlanner;
static constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
static constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
AnimationState resolveActiveState() const;
bool prepareGpuSkinning();
void drawAttachedWeapon(Renderer& renderer);
+308 -6
View File
@@ -1,7 +1,17 @@
#include "CharacterState.h"
#include "LocationState.h"
#include <algorithm>
#include <cmath>
namespace FRG {
namespace {
constexpr float ATTACK_COOLDOWN_TIME = 1.6f;
constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
constexpr float PI = 3.14159265358979323846f;
}
bool CharacterState::isMoving() const {
Eigen::Vector3f toTarget = walkTarget - position;
toTarget.y() = 0.f;
@@ -20,14 +30,304 @@ void CharacterState::stopInPlace() {
requestedWalkTarget = walkTarget;
pathWaypoints.clear();
currentWaypointIndex = 0;
onArrivedCallback = nullptr;
onArrivedCallbackName.clear();
}
void CharacterState::setTarget(
const Eigen::Vector3f& target,
std::string callbackName,
const LocationState& 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 LocationState& location, const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
pathWaypoints = location.planPathExceptMyself(position, normalizedTarget, entityId);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear();
}
void CharacterState::stopAndRotateToTarget(std::string characterId)
{
stopInPlace();
faceTargetId = std::move(characterId);
}
void CharacterState::resetPlayerAfterDeath()
{
if (!isPlayer) return;
currentState = AnimationState::STAND;
resetAnim = true;
showWeapon = false;
battle_state = 0;
attack = 0;
}
void CharacterState::tryAttackCombo()
{
if (static_cast<int>(animationFrame) < 20 ||
(currentState != AnimationState::ACTION_ATTACK &&
currentState != AnimationState::ACTION_ATTACK_2)) {
return;
}
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
void CharacterState::setHp(float newHp) {
hp = newHp;
if (hp < 0.f) hp = 0.f;
if (onHpChanged) onHpChanged(hp, initialHp, CharacterState::kNoTarget);
}
void CharacterState::setAnimationMetadata(
AnimationState state, int startFrame, int frameCount)
{
const auto index = static_cast<std::size_t>(state);
if (index < animationFrameCounts.size()) {
animationStartFrames[index] = (std::max)(0, startFrame);
animationFrameCounts[index] = (std::max)(1, frameCount);
}
}
CharacterUpdateEvents CharacterState::update(
std::int64_t deltaMs, const CharacterUpdateContext& context)
{
CharacterUpdateEvents events;
if (!enabled) return events;
if (slowMoTimeRemaining > 0.0f) {
slowMoTimeRemaining = (std::max)(
0.0f, slowMoTimeRemaining - static_cast<float>(deltaMs) / 1000.0f);
float transitionProgress = 1.0f;
if (slowMoTimeRemaining > slowMoActiveTime - slowMoTransitionTime) {
const float timeInTransition = slowMoActiveTime - slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / slowMoTransitionTime);
} else if (slowMoTimeRemaining < slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, slowMoTimeRemaining / slowMoTransitionTime);
}
const float speedMultiplier = isPlayer
? 1.0f + 0.5f * transitionProgress
: 1.0f - 0.9f * transitionProgress;
deltaMs = static_cast<std::int64_t>(
static_cast<float>(deltaMs) * speedMultiplier);
}
const CharacterObservation* attackTarget = context.attackTarget;
const CharacterObservation* faceTarget = context.faceTarget;
if (!attackTarget) {
battle_state = 0;
attack = 0;
attackTargetId.clear();
} else {
const float distance = (attackTarget->position - position).norm();
if (distance >= visibleRange) {
if (isPlayer &&
(attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else if (distance >= 1.25f) {
if ((attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else {
battle_state = 1;
}
}
const Eigen::Vector3f activeTarget = getCurrentNavigationTarget();
const Eigen::Vector3f lookTarget =
battle_state == 1 && attackTarget ? attackTarget->position : activeTarget;
if (currentState == AnimationState::WALK || currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - position;
toTarget.y() = 0.f;
const float distance = toTarget.norm();
if (distance > kWalkThreshold) {
const Eigen::Vector3f direction = toTarget / distance;
const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f;
position = moveAmount >= distance ? activeTarget : position + direction * moveAmount;
position.y() = 0.f;
targetFacingAngle = std::atan2(direction.x(), -direction.z());
currentState = AnimationState::WALK;
} else {
currentState = AnimationState::STAND;
const bool hasNextWaypoint =
!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size();
if (hasNextWaypoint) {
++currentWaypointIndex;
} else {
pathWaypoints.clear();
currentWaypointIndex = 0;
if (!onArrivedCallbackName.empty()) {
events.arrivedCallbackName = onArrivedCallbackName;
onArrivedCallbackName.clear();
}
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->position - position;
toFace.y() = 0.f;
const float distanceToFace = toFace.norm();
targetFacingAngle = distanceToFace > 1e-3f
? std::atan2(toFace.x() / distanceToFace, -toFace.z() / distanceToFace)
: facingAngle;
} else if (!faceTargetId.empty()) {
faceTargetId.clear();
}
}
}
if (hp <= 0.f) {
if (currentState != AnimationState::ACTION_TO_DEATH &&
currentState != AnimationState::DEATH_IDLE) {
currentState = AnimationState::ACTION_TO_DEATH;
resetAnim = true;
attackTargetId.clear();
}
} else if (battle_state == 1 && attackTarget) {
targetFacingAngle = std::atan2(
lookTarget.x() - position.x(), -(lookTarget.z() - position.z()));
if (currentState == AnimationState::STAND || currentState == AnimationState::WALK) {
currentState = AnimationState::STAND_TO_ACTION;
resetAnim = true;
}
if (canAttack && attack == 0 && attack_cooldown < 0.f &&
currentState == AnimationState::ACTION_IDLE) {
attack = 1;
attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (canAttack && attack_cooldown >= 0.f)
attack_cooldown -= static_cast<float>(deltaMs) / 1000.f;
if (attack == 1 && currentState == AnimationState::ACTION_IDLE &&
attackTarget->hp > 0.f) {
currentState = AnimationState::ACTION_ATTACK;
resetAnim = true;
}
} else if (currentState == AnimationState::STAND_TO_ACTION ||
currentState == AnimationState::ACTION_IDLE ||
currentState == AnimationState::ACTION_ATTACK) {
currentState = AnimationState::ACTION_TO_STAND;
resetAnim = true;
}
float angleDiff = targetFacingAngle - facingAngle;
while (angleDiff > PI) angleDiff -= 2.f * PI;
while (angleDiff < -PI) angleDiff += 2.f * PI;
const float rotationStep = rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotationStep) {
facingAngle = targetFacingAngle;
faceTargetId.clear();
} else {
facingAngle += angleDiff > 0.f ? rotationStep : -rotationStep;
}
if (!isPlayer && hp > 0.f && battle_state == 0 && maxHomeDrift > 0.f) {
homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (homeDriftCheckTimer <= 0.f) {
homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = homePosition - position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > maxHomeDrift) {
events.navigationRequested = true;
events.navigationTarget = homePosition;
}
}
}
if (resetAnim) {
resetAnim = false;
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
const int previousFrame = static_cast<int>(animationFrame);
animationFrame += static_cast<float>(deltaMs) / 24.f;
if (previousFrame < 19 && static_cast<int>(animationFrame) >= 19 &&
(currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) &&
attackTarget) {
events.attackImpact = true;
events.attackTargetId = attackTargetId;
events.attackDirection = attackTarget->position - position;
events.attackDirection.y() = 0.f;
const float length = events.attackDirection.norm();
if (length > 1e-3f) events.attackDirection /= length;
else events.attackDirection = Eigen::Vector3f::Zero();
}
showWeapon =
(static_cast<int>(animationFrame) >= 20 && currentState == AnimationState::STAND_TO_ACTION) ||
(static_cast<int>(animationFrame) <= 32 && currentState == AnimationState::ACTION_TO_STAND) ||
currentState == AnimationState::ACTION_ATTACK_2 ||
currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_IDLE;
const auto animationIndex = static_cast<std::size_t>(currentState);
const int frameCount = animationIndex < animationFrameCounts.size()
? animationFrameCounts[animationIndex] : 1;
if (frameCount > 1 && static_cast<int>(animationFrame) >= frameCount - 1) {
if (currentState == AnimationState::STAND_TO_ACTION)
currentState = AnimationState::ACTION_IDLE;
else if (currentState == AnimationState::ACTION_TO_STAND)
currentState = AnimationState::STAND;
else if (currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) {
currentState = AnimationState::ACTION_IDLE;
attack = 0;
} else if (currentState == AnimationState::ACTION_TO_DEATH) {
currentState = AnimationState::DEATH_IDLE;
events.deathAnimationComplete = true;
}
const auto nextIndex = static_cast<std::size_t>(currentState);
animationFrame = nextIndex < animationStartFrames.size()
? static_cast<float>(animationStartFrames[nextIndex]) : 0.f;
}
return events;
}
void CharacterState::save(nlohmann::json& out) const
@@ -41,8 +341,8 @@ void CharacterState::save(nlohmann::json& out) const
out["currentState"] = static_cast<int>(currentState);
out["attack_cooldown"] = attack_cooldown;
out["showWeapon"] = showWeapon;
out["attackTargetIndex"] = attackTargetIndex;
out["faceTargetIndex"] = faceTargetIndex;
out["attackTargetId"] = attackTargetId;
out["faceTargetId"] = faceTargetId;
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() };
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() };
out["requestedWalkTarget"] = { requestedWalkTarget.x(), requestedWalkTarget.y(), requestedWalkTarget.z() };
@@ -53,6 +353,7 @@ void CharacterState::save(nlohmann::json& out) const
out["pathWaypoints"] = std::move(wpArr);
out["currentWaypointIndex"] = currentWaypointIndex;
out["homeDriftCheckTimer"] = homeDriftCheckTimer;
out["animationFrame"] = animationFrame;
out["visibleRange"] = visibleRange;
if (!onArrivedCallbackName.empty())
out["onArrivedCallbackName"] = onArrivedCallbackName;
@@ -76,8 +377,8 @@ void CharacterState::load(const nlohmann::json& in)
currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState)));
attack_cooldown = in.value("attack_cooldown", attack_cooldown);
showWeapon = in.value("showWeapon", showWeapon);
attackTargetIndex = in.value("attackTargetIndex", attackTargetIndex);
faceTargetIndex = in.value("faceTargetIndex", faceTargetIndex);
attackTargetId = in.value("attackTargetId", attackTargetId);
faceTargetId = in.value("faceTargetId", faceTargetId);
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition);
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget);
if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3(in["requestedWalkTarget"], requestedWalkTarget);
@@ -89,6 +390,7 @@ void CharacterState::load(const nlohmann::json& in)
}
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
animationFrame = in.value("animationFrame", animationFrame);
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
}
+59 -20
View File
@@ -2,7 +2,9 @@
#include <string>
#include <vector>
#include <unordered_map>
#include <functional>
#include <array>
#include <cstdint>
#include <memory>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -12,6 +14,10 @@ namespace FRG {
constexpr float VISIBLE_RANGE = 6.f;
struct CharacterUpdateContext;
struct CharacterUpdateEvents;
struct LocationState;
enum class AnimationState {
STAND = 0,
WALK = 1,
@@ -24,6 +30,8 @@ enum class AnimationState {
DEATH_IDLE = 8,
};
// Paths and fixed parameters needed to recreate a Character's visual assets
// from scratch (e.g. after loading a saved game).
struct CharacterCreationInfo {
@@ -45,19 +53,14 @@ struct CharacterCreationInfo {
float weaponMeshScale = 1.0f;
};
// All serializable game state for a character.
// This is a member of Character; rendering-only data lives in Character itself.
// All serializable authoritative state for a character. GameState or
// LocationGameState owns it; rendering-only data lives in Character.
class CharacterState : public ISaveable {
public:
// Special index values for attackTargetIndex / faceTargetIndex
static constexpr int kNoTarget = -1; // no target (was nullptr)
static constexpr int kPlayerIndex = -2; // refers to Location::player
// Distance below which the character is considered to have reached its target.
static constexpr float kWalkThreshold = 0.05f;
// --- Identity ---
std::string npcId;
std::string npcName;
bool isPlayer = false;
bool enabled = true;
@@ -84,9 +87,11 @@ public:
// Weapon visibility toggle (changes during animation)
bool showWeapon = true;
// --- Index-based character references (replace raw pointers) ---
int attackTargetIndex = kNoTarget;
int faceTargetIndex = kNoTarget;
// --- Stable character references ---
// Empty means no target. "player" is the campaign player; NPC IDs are
// location-local and validated while loading location content.
std::string attackTargetId;
std::string faceTargetId;
// --- Home position for drift recovery ---
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero();
@@ -97,6 +102,13 @@ public:
float slowMoActiveTime = 6.0f;
float slowMoTransitionTime = 0.5f;
// --- Authoritative logical animation clock ---
// Frame counts are immutable metadata copied from loaded clips during
// character construction. Simulation advances animationFrame itself.
float animationFrame = 0.0f;
std::array<int, 9> animationStartFrames = {{0, 0, 0, 0, 0, 0, 0, 0, 0}};
std::array<int, 9> animationFrameCounts = {{1, 1, 1, 1, 1, 1, 1, 1, 1}};
// --- Movement / visual parameters ---
float walkSpeed = 3.0f;
float rotationSpeed = 4.0f;
@@ -115,14 +127,8 @@ public:
// Internal drift-check timer (serializable so it survives save/load).
float homeDriftCheckTimer = 5.0f;
// --- Non-serializable callbacks (re-wired after each load) ---
std::function<void()> onArrivedCallback;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, int)> onHpChanged;
// Global Lua function name backing onArrivedCallback, if it was sourced from a
// script (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback
// can be re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the character reaches the final waypoint.
// The name is serialized, so no runtime callback needs to be rehydrated.
std::string onArrivedCallbackName;
// --- Asset / creation info (used to reload visuals from a saved state) ---
@@ -133,12 +139,45 @@ public:
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
Eigen::Vector3f getCurrentNavigationTarget() const;
void stopInPlace();
void setTarget(const Eigen::Vector3f& target,
std::string callbackName,
const LocationState& location,
const std::string& entityId);
void forceReplan(const LocationState& location,
const std::string& entityId);
void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath();
void tryAttackCombo();
float getHp() const { return hp; }
void setHp(float newHp);
CharacterUpdateEvents update(std::int64_t deltaMs, const CharacterUpdateContext& context);
void setAnimationMetadata(AnimationState state, int startFrame, int frameCount);
// --- Serialisation (runtime mutable fields only; creation info is reloaded from config) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
};
using PlayerStateMap =
std::unordered_map<std::string, std::shared_ptr<CharacterState>>;
struct CharacterObservation {
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float hp = 0.f;
};
struct CharacterUpdateContext {
const CharacterObservation* attackTarget = nullptr;
const CharacterObservation* faceTarget = nullptr;
};
struct CharacterUpdateEvents {
bool attackImpact = false;
bool deathAnimationComplete = false;
bool navigationRequested = false;
Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero();
std::string arrivedCallbackName;
std::string attackTargetId;
Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero();
};
} // namespace FRG
+149 -84
View File
@@ -77,15 +77,18 @@ namespace FRG
Location* Game::currentLocation() const {
if (gameState.currentLocationName.empty()) return nullptr;
auto it = gameState.locations.find(gameState.currentLocationName);
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
const auto& locations = internalGameServer.runtimeLocations();
auto it = locations.find(gameState.currentLocationName);
return (it != locations.end()) ? it->second.get() : nullptr;
}
Game::Game()
: newTickCount(0)
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, internalGameServer()
, gameState(internalGameServer.hostState())
, menuManager(renderer, internalGameServer, *audioPlayer)
, newTickCount(0)
, lastTickCount(0)
, audioPlayer(std::make_unique<AudioPlayerAsync>())
, menuManager(renderer, gameState, *audioPlayer)
{
}
@@ -391,15 +394,23 @@ namespace FRG
menuManager.skipCutsceneFunc = [this]() {
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
};
menuManager.callTaxiFunc = [this]() {
if (currentLocation()) currentLocation()->onTaxiCalled();
};
});
loadSteps.push([this]() {
try {
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
menuManager.setup(CONST_ZIP_FILE);
gameState.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestNew, quest->definition.title);
};
gameState.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestCompleted, quest->definition.title);
};
gameState.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestFailed, quest->definition.title);
};
logger() << "UI loaded successfully" << std::endl;
}
catch (const std::exception& e) {
@@ -515,7 +526,8 @@ namespace FRG
}
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
return player && loadedCampaignId == campaignId && gameState.locations.count(locationId) > 0;
return player && loadedCampaignId == campaignId
&& internalGameServer.runtimeLocations().count(locationId) > 0;
}
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
@@ -556,20 +568,24 @@ namespace FRG
void Game::loadCampaign(const std::string& campaignId) {
loadedCampaignId = campaignId;
gameState.campaignId = campaignId;
gameState.storedLocationStates.clear();
gameState.locations.clear();
gameState.players.clear();
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
// The player stays loaded for the whole campaign; locations come and go around him.
player = Location::createPlayer(renderer, audioPlayer.get());
auto& playerState = gameState.players["player"];
if (!playerState) playerState = std::make_shared<CharacterState>();
player = Location::createPlayer(renderer, audioPlayer.get(), *playerState, internalGameServer);
if (player)
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
}
void Game::unloadCampaign() {
unloadLocation(false);
player.reset();
gameState.players.clear();
loadedCampaignId.clear();
gameState.storedLocationStates.clear();
gameState.locations.clear();
renderer.textureManager.UnloadUnused(); // the player's textures
}
@@ -580,23 +596,24 @@ namespace FRG
<< locationId << "'" << std::endl;
return;
}
const bool initializeLocation =
!gameState.locations[locationId].hasPersistentState;
std::shared_ptr<Location> loc = createLocation(*config);
wireLocation(loc.get());
if (shadowMap) loc->shadowMap = shadowMap;
if (initializeLocation)
loc->scriptEngine.callLocationInitCallback();
// Back to how the player left it (or to the start state's snapshot of it).
auto stored = gameState.storedLocationStates.find(locationId);
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
gameState.locations[locationId] = loc;
internalGameServer.bindRuntimeLocation(locationId, loc);
}
void Game::unloadLocation(bool storeState) {
for (auto& [name, loc] : gameState.locations) {
if (!storeState) continue;
nlohmann::json locJson;
loc->save(locJson);
gameState.storedLocationStates[name] = std::move(locJson);
if (storeState) internalGameServer.synchronizeRuntimeAuxiliaryState();
else {
for (const auto& [name, loc] : internalGameServer.runtimeLocations())
gameState.locations.erase(name);
}
gameState.locations.clear();
internalGameServer.runtimeLocations().clear();
// Textures only the location's models and characters used are freed now.
renderer.textureManager.UnloadUnused();
}
@@ -619,11 +636,12 @@ namespace FRG
Location* loc = currentLocation();
if (!loc) return;
if (loc->player) {
loc->player->state.position = destPos;
loc->player->setTarget(destPos);
loc->player->stopInPlace();
loc->player->state.facingAngle = destRotY;
loc->player->state.targetFacingAngle = destRotY;
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
workingState.position = destPos;
workingState.facingAngle = destRotY;
workingState.targetFacingAngle = destRotY;
});
loc->stopCharacter(*loc->player);
}
loc->state.cameraAzimuth = destRotY;
loc->state.isDarklands = gameState.isDarklands;
@@ -632,14 +650,16 @@ namespace FRG
loc->scriptEngine.callLocationEnterCallback();
//Reset taxi
loc->state.taxiCar = loc->state.taxiCarInitialState;
loc->state.taxiArrivedCallbackCalled = false;
auto locationState = gameState.locations.find(destName);
if (locationState != gameState.locations.end())
locationState->second.resetTaxi();
gameState.taxiIsCalled = false;
menuManager.onLocationChanged(destName);
});
}
void Game::performFullReset() {
gameState.currentLocationName.clear();
gameState.isDarklands = false;
@@ -666,9 +686,9 @@ namespace FRG
// Rebuild the loaded location from its config; the others have no stored state
// any more, so they are built fresh when the player first goes there.
gameState.storedLocationStates.clear();
gameState.locations.clear();
std::vector<std::string> loadedIds;
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loadedIds.push_back(name);
unloadLocation(false);
for (const std::string& id : loadedIds) loadLocation(id);
@@ -698,15 +718,8 @@ namespace FRG
if (!content.empty()) {
try {
nlohmann::json root = nlohmann::json::parse(content);
const std::string campaignId = gameState.campaignId;
// GameState::load() merges into existing maps rather than replacing them,
// so any Lua globals set during play that aren't in the pristine snapshot
// must be cleared explicitly before loading it.
gameState.globalInts.clear();
gameState.globalFloats.clear();
gameState.load(root); // applies to the loaded location in place and stores
internalGameServer.load(root); // applies to the loaded location in place and stores
// the other locations' states for when they load.
gameState.campaignId = campaignId; // the snapshot may predate campaigns
return;
} catch (const std::exception& e) {
logger() << "[reset] Failed to parse " << path << ": " << e.what()
@@ -729,7 +742,7 @@ namespace FRG
void Game::saveInitialState() {
nlohmann::json root;
gameState.save(root);
internalGameServer.save(root);
// Written next to the executable; copy it over the campaign's start-state file.
const std::string startStatePath = activeCampaign().startStatePath;
const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1);
@@ -747,20 +760,28 @@ namespace FRG
std::shared_ptr<Location> Game::createLocation(const LocationConfig& config)
{
LocationGameState& authoritative = gameState.locations[config.id];
if (!authoritative.location) authoritative.location = std::make_shared<LocationState>();
if (authoritative.location->navigationMaps.empty())
authoritative.location->initNavigation(config.navigationJsonPaths);
LocationSetup params;
params.gameObjectsJsonPath = config.gameObjectsJsonPath;
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
params.npcsJsonPath = config.npcsJsonPath;
params.dialoguesJsonPath = config.dialoguesJsonPath;
params.navigationJsonPaths = config.navigationJsonPaths;
//params.navigationJsonPaths = config.navigationJsonPaths;
params.scriptPath = config.scriptPath;
params.teleportsJsonPath = config.teleportsJsonPath;
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
params.lightsJsonPath = config.lightsJsonPath;
params.playerPosition = config.playerPosition;
params.player = player;
params.gameState = &authoritative;
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
auto loc = std::make_shared<Location>(
renderer, gameState.inventory, *authoritative.location, internalGameServer, config.id);
loc->setup(params, &gameState.questJournal, audioPlayer.get());
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
@@ -779,8 +800,8 @@ namespace FRG
menuManager.setChatUnread(idx, unread, msg);
};
for (auto& npc : loc->npcs) {
if (npc && npc->state.canAttack) {
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
if (npc && npc->characterState.canAttack) {
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
}
}
loc->requestDarklandsPlayBattleMusic = [this]() {
@@ -792,24 +813,23 @@ namespace FRG
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
};
if (config.lockInteractiveObjectsDuringTutorial) {
// Objects can be picked up once the tutorial reaches "pick up an item".
loc->areInteractiveObjectsLocked = [this]() {
return gameState.tutorialStep < TutorialStep::PickUpItem;
};
}
authoritative.lockInteractiveObjectsDuringTutorial =
config.lockInteractiveObjectsDuringTutorial;
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;
LocationState& locationState = *authoritative.location;
locationState.taxiCarTriggerPosition = taxi.triggerPosition;
locationState.taxiCarWaypoints = taxi.waypoints;
locationState.taxiCarInitialState.position = taxi.initialPosition;
locationState.taxiCarInitialState.rotation = taxi.initialRotation;
locationState.taxiCarInitialState.mode = NpcCar::Mode::NONE;
locationState.taxiCarInitialState.velocity = 0.f;
locationState.taxiCarInitialState.steeringAngle = 0.f;
locationState.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
locationState.taxiCar.waypointReachRadius = taxi.waypointReachRadius;
if (!authoritative.hasPersistentState)
authoritative.resetTaxi();
}
return loc;
@@ -866,24 +886,30 @@ namespace FRG
if (loc->player) {
// Raw pointer: the player belongs to the location (and outlives it), so a
// shared_ptr here would keep unloaded locations alive. Rewired on every load.
loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) {
loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) {
menuManager.updateHealthBar(hp, maxHp);
if (attackerIndex != CharacterState::kNoTarget) {
if (!attackerId.empty()) {
//Someone attacked, cancel dialogue and agro the player
if (loc->dialogueSystem.isDialogueActive()) {
loc->dialogueSystem.stopDialogue();
}
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
((loc->player->state.attackTargetIndex >= 0) &&
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
bool currentTargetDead = false;
for (const auto& npc : loc->npcs) {
if (npc && npc->entityId() == loc->player->characterState.attackTargetId) {
currentTargetDead = npc->characterState.hp <= 0.f;
break;
}
}
if ((loc->player->characterState.attackTargetId.empty() || currentTargetDead)
&&
loc->player->isMoving() == false
&&
loc->player->getHp() > 0) {
loc->player->state.attackTargetIndex = attackerIndex;
internalGameServer.submit(SetCharacterTargetCommand{
loc->locationId(), "player", attackerId});
}
}
@@ -905,7 +931,7 @@ namespace FRG
CheckGlError(__FILE__, __LINE__);
logger() << "Shadow map initialized" << std::endl;
for (auto& [name, loc] : gameState.locations) loc->shadowMap = shadowMap;
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap = shadowMap;
}
void Game::destroyShadowMapAndProparateToLocations()
@@ -915,7 +941,7 @@ namespace FRG
shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
for (auto& [name, loc] : gameState.locations) loc->shadowMap.reset();
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
}
@@ -1177,7 +1203,9 @@ namespace FRG
if (loadingCompleted && currentLocation())
{
currentLocation()->update(delta);
// The authoritative implementation advances simulation; a future
// network client can use the same call to poll/apply server updates.
internalGameServer.update(delta);
}
}
@@ -1228,6 +1256,10 @@ namespace FRG
}
void Game::update() {
// Host systems (locations, scripts and loading) still execute in-process.
// Publish any changes they made since the previous frame as a new immutable
// revision before presentation clients query the server.
internalGameServer.markHostMutation();
SDL_Event event;
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
while (SDL_PollEvent(&event)) {
@@ -1332,8 +1364,9 @@ namespace FRG
}
else if (event.button.button == SDL_BUTTON_RIGHT
&& event.type == SDL_MOUSEBUTTONUP
&& editorMode == EditorMode::Navigation
&& currentLocation()) {
&& currentLocation()
&& currentLocation()->editorMode == EditorMode::Navigation
) {
currentLocation()->editor.handleRightClick();
}
}
@@ -1378,15 +1411,32 @@ namespace FRG
}
if (event.key.keysym.sym == SDLK_1)
{
startNightTransition();
currentLocation()->switchNavigation(0);
//startNightTransition();
}
if (event.key.keysym.sym == SDLK_2)
{
startDarklandsTransition();
currentLocation()->switchNavigation(1);
//startDarklandsTransition();
}
if (event.key.keysym.sym == SDLK_3)
{
logger() << "Player position: " << currentLocation()->player->state.position.transpose() << std::endl;
currentLocation()->switchNavigation(2);
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
if (event.key.keysym.sym == SDLK_n)
{
if (currentLocation()->editorMode == EditorMode::None)
{
currentLocation()->editorMode = EditorMode::Navigation;
}
else
{
currentLocation()->editorMode = EditorMode::None;
}
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
}
@@ -1438,12 +1488,18 @@ namespace FRG
if (!currentLocation()) return;
if (currentLocation()->player) {
currentLocation()->player->state.slowMoTimeRemaining = currentLocation()->player->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*currentLocation()->player, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
for (auto& npc : currentLocation()->npcs) {
if (npc) {
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*npc, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
}
}
@@ -1816,10 +1872,11 @@ namespace FRG
if (content.empty()) return {};
try {
nlohmann::json root = nlohmann::json::parse(content);
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {};
SaveSlotInfo info;
info.empty = false;
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
info.locationName = root.value("currentLocationName", "");
info.campaignId = root.at("campaignId").get<std::string>();
info.locationName = root.at("currentLocationId").get<std::string>();
info.savedAt = root.value("savedAt", "");
return info;
} catch (...) {
@@ -1830,7 +1887,7 @@ namespace FRG
void Game::saveGame(int slot)
{
nlohmann::json root;
gameState.save(root);
internalGameServer.save(root);
std::string path = "save_slot" + std::to_string(slot) + ".json";
/*std::ofstream file(path);
@@ -1858,11 +1915,19 @@ namespace FRG
return;
}
// The save's campaign and location may not be loaded yet; the state is applied once they are.
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
const std::string locationId = root.value("currentLocationName", std::string());
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) {
std::cerr << "[save] Unsupported save schema in " << path << std::endl;
return;
}
const std::string campaignId = root.value("campaignId", std::string());
const std::string locationId = root.value("currentLocationId", std::string());
const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId);
if (!campaign || !campaign->findLocation(locationId)) {
std::cerr << "[save] Unknown campaign/location in " << path << std::endl;
return;
}
enterLocationThen(campaignId, locationId, [this, root]() {
gameState.load(root);
internalGameServer.load(root);
playMusicForLoadedGame();
menuManager.enterGameplay(true);
});
+15 -8
View File
@@ -22,6 +22,7 @@
#include "Location.h"
#include "AudioPlayerAsync.h"
#include "GameState.h"
#include "game_server/InternalGameServer.h"
namespace FRG {
struct LocationConfig;
@@ -73,9 +74,7 @@ namespace FRG {
// content reload, don't have a meaningful denominator, so the bar is hidden.
bool showLoadingProgressBar = true;
GameState gameState;
EditorMode editorMode = EditorMode::None;
//EditorMode editorMode = EditorMode::None;
// Returns false if a transition is already in progress.
bool startDarklandsTransition();
@@ -83,6 +82,16 @@ namespace FRG {
std::unique_ptr<AudioPlayerAsync> audioPlayer;
private:
InternalGameServer internalGameServer;
// Host-only alias while location/script orchestration is moved behind typed
// commands. The referenced object is owned by internalGameServer.
GameState& gameState;
public:
IGameServer& server() { return internalGameServer; }
const IGameServer& server() const { return internalGameServer; }
MenuManager menuManager;
void activateSlowMoEffect();
@@ -128,10 +137,8 @@ namespace FRG {
void loadGame(int slot);
void playMusicForLoadedGame();
SaveSlotInfo readSlotInfo(int slot) const;
// Loading model: one campaign at a time, and within it only the location the player
// is in (gameState.locations). Leaving a location stores its state in
// gameState.storedLocationStates and releases it; the player is created once per
// campaign and shared by its locations.
// Loading model: one campaign at a time. InternalGameServer privately owns the
// one live Location while GameState retains pure value state for every location.
std::string loadedCampaignId;
std::shared_ptr<Character> player;
@@ -146,7 +153,7 @@ namespace FRG {
void unloadCampaign();
// Builds the campaign's location and restores its stored state, if any.
void loadLocation(const std::string& locationId);
// Releases the loaded location, keeping its state in storedLocationStates if asked.
// Releases the loaded runtime location, retaining its value state when requested.
void unloadLocation(bool storeState);
// Teleport zones: moves the player to another location of the campaign.
void teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY);
+70 -44
View File
@@ -2,23 +2,10 @@
#include <ctime>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace FRG {
// Saves made before the tutorial rework kept the dorm and the university tutorials
// as two state machines (tutorialStep 0..6 + uniIntTutorialState 0..4); map them
// onto the single TutorialStep sequence.
static TutorialStep migrateLegacyTutorialStep(int dormStep, int uniIntState)
{
if (dormStep <= 4) return static_cast<TutorialStep>(dormStep); // Step0..Step4 kept their values
switch (uniIntState) {
case 2: return TutorialStep::DarklandsHealth; // DarklandsActive
case 3: return TutorialStep::DarklandsAttack; // DarklandsStep13
case 4: return TutorialStep::Completed; // DarklandsFull
default: return TutorialStep::EnterDarklands; // Step10 / Step11
}
}
bool GameState::completeTutorialStep(TutorialStep step)
{
if (tutorialStep != step || step == TutorialStep::Completed) return false;
@@ -26,8 +13,29 @@ bool GameState::completeTutorialStep(TutorialStep step)
return true;
}
CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId)
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
const CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId) const
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
void GameState::save(nlohmann::json& out) const
{
out["schemaVersion"] = kSchemaVersion;
// Timestamp
std::time_t now = std::time(nullptr);
std::tm* tm = std::localtime(&now);
@@ -37,7 +45,7 @@ void GameState::save(nlohmann::json& out) const
// --- World ---
out["campaignId"] = campaignId;
out["currentLocationName"] = currentLocationName;
out["currentLocationId"] = currentLocationName;
out["isDarklands"] = isDarklands;
out["isNight"] = isNight;
out["isDawn"] = isDawn;
@@ -87,16 +95,21 @@ void GameState::save(nlohmann::json& out) const
questJournal.save(questJson);
out["questJournal"] = std::move(questJson);
// --- Players ---
out["players"] = nlohmann::json::object();
for (const auto& [id, player] : players) {
if (!player) continue;
nlohmann::json playerJson;
player->save(playerJson);
out["players"][id] = std::move(playerJson);
}
// --- Locations ---
// Unloaded locations keep their last stored state; loaded ones are saved live.
nlohmann::json locsJson = nlohmann::json::object();
for (const auto& [name, stored] : storedLocationStates) locsJson[name] = stored;
for (const auto& [name, loc] : locations) {
if (loc) {
nlohmann::json locJson;
loc->save(locJson);
locsJson[name] = std::move(locJson);
}
for (const auto& [name, location] : locations) {
nlohmann::json locJson;
location.save(locJson);
locsJson[name] = std::move(locJson);
}
out["locations"] = std::move(locsJson);
@@ -105,12 +118,16 @@ void GameState::save(nlohmann::json& out) const
void GameState::load(const nlohmann::json& in)
{
const int schemaVersion = in.value("schemaVersion", -1);
if (schemaVersion != kSchemaVersion)
throw std::runtime_error(
"Unsupported game-state schema version: " +
std::to_string(schemaVersion));
//logger() << "GameState::load step 1" << std::endl;
// --- World ---
// Literal default: saves made before campaigns existed belong to the main game.
campaignId = in.value("campaignId", std::string("main"));
currentLocationName = in.value("currentLocationName", currentLocationName);
campaignId = in.at("campaignId").get<std::string>();
currentLocationName = in.at("currentLocationId").get<std::string>();
isDarklands = in.value("isDarklands", isDarklands);
isNight = in.value("isNight", isNight);
isDawn = in.value("isDawn", isDawn);
@@ -142,11 +159,8 @@ void GameState::load(const nlohmann::json& in)
//logger() << "GameState::load step 5" << std::endl;
// --- Tutorial ---
if (in.contains("uniIntTutorialState")) {
tutorialStep = migrateLegacyTutorialStep(in.value("tutorialStep", 0), in["uniIntTutorialState"].get<int>());
} else {
tutorialStep = static_cast<TutorialStep>(in.value("tutorialStep", static_cast<int>(tutorialStep)));
}
tutorialStep = static_cast<TutorialStep>(
in.at("tutorialStep").get<int>());
tutorialPhoneChatScreenOpened = in.value("tutorialPhoneChatScreenOpened", tutorialPhoneChatScreenOpened);
tutorialJournalScreenOpened = in.value("tutorialJournalScreenOpened", tutorialJournalScreenOpened);
tutorialNeedOpenTaxiScreen = in.value("tutorialNeedOpenTaxiScreen", tutorialNeedOpenTaxiScreen);
@@ -193,20 +207,32 @@ void GameState::load(const nlohmann::json& in)
logger() << "GameState::load step 10" << std::endl;
// --- Locations ---
// Every location's state is stored (applied when the location loads); the ones
// already loaded get it right away.
storedLocationStates.clear();
if (in.contains("locations") && in["locations"].is_object()) {
for (const auto& [name, locJson] : in["locations"].items()) {
//logger() << "GameState::load step 10.1: " << name << std::endl;
storedLocationStates[name] = locJson;
// --- Players ---
const auto& playerValues = in.at("players");
if (!playerValues.is_object() || !playerValues.contains("player"))
throw std::runtime_error("Game state requires players.player");
for (const auto& [id, value] : playerValues.items()) {
auto& player = players[id];
if (!player) player = std::make_shared<CharacterState>();
player->load(value);
}
for (auto it = players.begin(); it != players.end();) {
if (!playerValues.contains(it->first)) it = players.erase(it);
else ++it;
}
auto it = locations.find(name);
if (it != locations.end() && it->second) {
it->second->load(locJson);
}
}
// --- Locations ---
const auto& locationValues = in.at("locations");
if (!locationValues.is_object() ||
!locationValues.contains(currentLocationName))
throw std::runtime_error(
"Game state does not contain current location '" +
currentLocationName + "'");
for (const auto& [id, value] : locationValues.items())
locations[id].load(value);
for (auto it = locations.begin(); it != locations.end();) {
if (!locationValues.contains(it->first)) it = locations.erase(it);
else ++it;
}
//logger() << "GameState::load step 11" << std::endl;
+13 -8
View File
@@ -1,7 +1,7 @@
#pragma once
#include "items/Item.h"
#include "quest/QuestJournal.h"
#include "Location.h"
#include "LocationGameState.h"
#include "ISaveable.h"
#include "external/nlohmann/json.hpp"
#include <unordered_map>
@@ -32,17 +32,17 @@ struct StoredChatMessage {
bool incoming;
};
using LocationMap = std::unordered_map<std::string, std::shared_ptr<Location>>;
struct GameState : public ISaveable {
static constexpr int kSchemaVersion = 1;
// --- World ---
// Campaign being played (see CampaignRegistry).
std::string campaignId = "main";
// The campaign's loaded location(s); only the one the player is in is kept loaded.
LocationMap locations;
// Saved state (Location::save) of the campaign's locations that are not loaded,
// applied when one is loaded again. save()/load() cover both maps.
std::unordered_map<std::string, nlohmann::json> storedLocationStates;
// Pure authoritative location data. Live Location/render/script instances are
// private runtime storage in InternalGameServer and never enter GameState.
std::unordered_map<std::string, LocationGameState> locations;
// Players are campaign-level entities and remain stable across locations.
PlayerStateMap players;
std::string currentLocationName;
bool isDarklands = false;
bool isNight = false;
@@ -73,6 +73,11 @@ struct GameState : public ISaveable {
// Moves to the next step if `step` is the current one; returns whether it did.
bool completeTutorialStep(TutorialStep step);
CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId);
const CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId) const;
// --- Phone / chat ---
bool chatUnread[3] = { true, true, true };
std::string chatPreviewMsg[3];
+290 -783
View File
File diff suppressed because it is too large. Load diff
+37 -42
View File
@@ -16,13 +16,15 @@
#include "TeleportZone.h"
#include "LocationEditor.h"
#include "LocationState.h"
#include "ISaveable.h"
#include "LocationGameState.h"
#include <functional>
#include <cstdint>
#include <unordered_map>
#include "Character.h"
namespace FRG
{
class IGameServer;
struct PointLight
{
@@ -35,7 +37,7 @@ namespace FRG
// Rectangular limits — keep light confined to a room-sized AABB centred on the light
float limitX = 99999.f; // shader fragment limit: half-width in world X
float limitY = 99999.f; // shader fragment limit: half-width in world Z
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use legacy nearest-light logic
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use nearest-light logic
float autoLightLimitY = -1.0f; // activation rect half-width Z
};
@@ -55,7 +57,7 @@ namespace FRG
std::string interactiveObjectsJsonPath;
std::string npcsJsonPath;
std::string dialoguesJsonPath;
std::vector<std::string> navigationJsonPaths;
//std::vector<std::string> navigationJsonPaths;
std::string scriptPath;
std::string teleportsJsonPath;
std::string triggerZonesJsonPath;
@@ -64,17 +66,17 @@ namespace FRG
// Player shared by all locations of a campaign (see Location::createPlayer);
// setup() creates a private one when this is null.
std::shared_ptr<Character> player;
LocationGameState* gameState = nullptr;
};
class Location : public ISaveable
class Location
{
public:
Location(Renderer& iRenderer, Inventory& iInventory);
Location(Renderer& iRenderer, Inventory& iInventory, LocationState& state, IGameServer& server,
std::string locationId);
// Detaches the (shared, longer-lived) player from this location.
~Location();
// ---- All serialisable runtime state ----
LocationState state;
const std::string& locationId() const { return locationId_; }
std::unordered_map<std::string, LoadedGameObject> gameObjects;
@@ -88,10 +90,11 @@ namespace FRG
std::vector<std::unique_ptr<Character>> npcs;
// Builds the player character (models, animations, weapon, textures).
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer);
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer,
CharacterState& state, IGameServer& server);
std::vector<PathFinder> navigationMaps;
PathFinder* navigation = nullptr;
//std::vector<PathFinder> navigationMaps;
//int state.activeNavigationIndex = 0;
std::shared_ptr<ShadowMap> shadowMap;
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
@@ -114,10 +117,6 @@ namespace FRG
std::function<void()> onPlayerTaxiRequired;
// Optional, set by Game. While it returns true the player can't pick up /
// use interactive objects (the dorm tutorial unlocks them step by step).
std::function<bool()> areInteractiveObjectsLocked;
// Set by Game after setup(). Lua and onDeathAnimComplete call this to
// request the transition without coupling Location to Game directly.
std::function<bool()> requestDarklandsTransition;
@@ -140,7 +139,7 @@ namespace FRG
EditorMode editorMode = EditorMode::None;
LocationEditor editor;
std::vector<std::string> navigationMapPaths;
//std::vector<std::string> navigationMapPaths;
// Set by Game when the user's primary pointer (left mouse / single touch)
// has crossed the tap-vs-drag threshold and is now rotating the camera.
@@ -162,8 +161,20 @@ namespace FRG
void setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer);
void setupNavigation(const std::vector<std::string>& paths);
//void setupNavigation(const std::vector<std::string>& paths);
bool switchNavigation(int index);
void setCharacterTarget(Character& character, const Eigen::Vector3f& target,
std::string callbackName = "");
void updateCharacterState(
Character& character,
const std::function<void(CharacterState&)>& mutation);
void clearCharacterAttackTarget(Character& character);
void stopCharacter(Character& character);
void faceCharacter(Character& character, std::string targetId);
void replanCharacter(Character& character);
void resetCharacterAfterDeath(Character& character);
void tryCharacterAttackCombo(Character& character);
void setCharacterHp(Character& character, float hp);
InteractiveObject* raycastInteractiveObjects(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir);
Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f);
@@ -177,8 +188,7 @@ namespace FRG
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
void update(int64_t deltaMs);
void updateNpcCar(int64_t deltaMs);
void update(int64_t deltaMs, const LocationSimulationEvents& events);
// Purely visual feedback for click-triggered player routes. Disabling it
// also cancels an animation that is currently playing.
void setPathVisualizationEnabled(bool enabled);
@@ -194,26 +204,21 @@ namespace FRG
void setDialogueFlag(const std::string& flag, int value);
int getDialogueFlag(const std::string& flag) const;
void onTaxiCalled();
// ---- Save / Load ----
// Serialises LocationState + all Character/InteractiveObject sub-states + Lua globals.
void save(nlohmann::json& out) const override;
// Restores state from JSON written by save.
// Navigation pointer and triggerZone.playerInside are re-synced automatically.
void load(const nlohmann::json& in) override;
protected:
friend class LocationEditor;
friend class Game;
friend class ScriptEngine;
friend class InternalGameServer;
Renderer& renderer;
Inventory& inventory;
AudioPlayerAsync* audioPlayer;
private:
LocationState& state; // non-owning binding to LocationGameState
IGameServer& gameServer_;
std::string locationId_;
// ---- Pointer-to-index helpers ----
InteractiveObject* getTargetInteractiveObject();
TeleportZone* getTargetTeleportZone();
int findInteractiveObjectIndex(const InteractiveObject* obj) const;
int findTeleportZoneIndex(const TeleportZone* tz) const;
enum class InstancedTexture { None, Color, Darklands };
@@ -223,26 +228,16 @@ namespace FRG
void drawInstancedStatics(const std::string& instancedShaderName, InstancedTexture textureKind,
const std::function<void()>& setUniforms = {});
void resolveCharacterCollisions();
void updateDynamicReplans(int64_t deltaMs);
void loadTeleportZones(const std::string& jsonPath, const char* zipFile);
void loadTriggerZones(const std::string& jsonPath, const char* zipFile);
void loadPointLights(const std::string& jsonPath, const char* zipFile);
void updateTriggerZones(const Eigen::Vector3f& playerPos);
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
int findNpcIndex(const Character* npc) const;
void fireBumpCallbacks(Character* mover, Character* standing);
void captureAuxiliaryState(LocationGameState& out) const;
void applyAuxiliaryState(const LocationGameState& in);
PathVisualization pathVisualization;
std::unordered_map<Character*, Eigen::Vector3f> lastCharacterPositions;
std::unordered_map<Character*, int64_t> replanCooldownRemainingMs;
std::unordered_map<Character*, int> stuckFrameCounts;
std::unordered_map<Character*, float> stuckPauseRemainingS;
static constexpr float NPC_BUMP_CALLBACK_COOLDOWN = 5.0f;
std::unordered_map<int, float> npcBumpedByPlayerCooldown;
std::unordered_map<int, float> npcBumpsPlayerCooldown;
};
} // namespace FRG
+20 -16
View File
@@ -24,10 +24,9 @@ namespace FRG
void LocationEditor::buildNavMeshes()
{
navigationEditorNavMeshes.clear();
if (!loc.navigation) return;
const float y = loc.navigation->getFloorY() + 0.02f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
for (const auto& obs : loc.navigation->getObstaclePolygons()) {
for (const auto& obs : loc.state.navigationMaps[loc.state.activeNavigationIndex].getObstaclePolygons()) {
if (obs.polygon.size() < 3) continue;
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
@@ -51,7 +50,7 @@ namespace FRG
{
VertexDataStruct data;
const Eigen::Vector3f yellow(1.0f, 1.0f, 0.0f);
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.05f : 0.05f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.05f;
const float s = 0.2f;
for (const auto& pt : navigationEditorPoints) {
@@ -68,12 +67,19 @@ namespace FRG
}
navigationEditorPointsMesh.data = std::move(data);
navigationEditorPointsMesh.RefreshVBO();
if (navigationEditorPointsMesh.data.PositionData.size() > 0)
{
navigationEditorPointsMesh.RefreshVBO();
}
}
void LocationEditor::drawPoints()
{
if (navigationEditorPoints.empty()) return;
if (navigationEditorPointsMesh.data.PositionData.empty())
{
return;
}
loc.renderer.shaderManager.PushShader("defaultColor");
loc.renderer.SetMatrix();
loc.renderer.DrawVertexRenderStruct(navigationEditorPointsMesh);
@@ -120,12 +126,12 @@ namespace FRG
poly.polygon.emplace_back(pt.x(), pt.z());
}
if (loc.navigation) loc.navigation->addObstaclePolygon(poly);
loc.state.navigationMaps[loc.state.activeNavigationIndex].addObstaclePolygon(poly);
logger() << "[NAV_EDITOR] Added obstacle '" << poly.name << "' with "
<< poly.polygon.size() << " vertices\n";
{
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.02f : 0.02f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
@@ -147,14 +153,12 @@ namespace FRG
if (!std::filesystem::exists(baseName + ".json")) break;
}
if (!loc.navigation) return;
if (loc.navigation->saveConfig(baseName + ".json"))
if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveConfig(baseName + ".json"))
logger() << "[NAV_EDITOR] Saved config to: " << baseName << ".json\n";
else
std::cerr << "[NAV_EDITOR] Failed to save config to: " << baseName << ".json\n";
if (loc.navigation->saveGrid(baseName + ".txt"))
if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveGrid(baseName + ".txt"))
logger() << "[NAV_EDITOR] Saved grid to: " << baseName << ".txt\n";
else
std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n";
@@ -162,8 +166,8 @@ namespace FRG
void LocationEditor::reload()
{
loc.setupNavigation(loc.navigationMapPaths);
logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
//loc.setupNavigation(loc.navigationMapPaths);
//logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
}
static VertexDataStruct CreateBoundsBoxMesh(const Eigen::Vector3f corners[8], const Eigen::Vector3f& color)
@@ -454,9 +458,9 @@ namespace FRG
data.rotationX = 0.0f;
data.rotationY = rotDist(rng);
data.rotationZ = 0.0f;
data.positionX = loc.player->state.position.x();
data.positionY = loc.player->state.position.y();//-0.1;
data.positionZ = loc.player->state.position.z();
data.positionX = loc.player->characterState.position.x();
data.positionY = loc.player->characterState.position.y();//-0.1;
data.positionZ = loc.player->characterState.position.z();
data.scale = scaleDist(rng);
GameObjectData data2;
+351
View File
@@ -0,0 +1,351 @@
#include "LocationGameState.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace FRG {
namespace {
constexpr float PI = 3.14159265358979323846f;
constexpr float NPC_TALK_DISTANCE = 1.35f;
}
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<LocationState>();
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
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "CharacterState.h"
#include "LocationState.h"
#include "items/InteractiveObjectState.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<LocationState> location = std::make_shared<LocationState>();
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();
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
+83 -206
View File
@@ -2,6 +2,76 @@
namespace FRG {
extern const char* CONST_ZIP_FILE;
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> LocationState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const
{
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {});
}
void LocationState::initNavigation(const std::vector<std::string>& paths)
{
const int savedActiveNavigationIndex = activeNavigationIndex;
//navigationMapPaths = paths;
navigationMaps.clear();
navigationMaps.resize(paths.size());
for (size_t i = 0; i < paths.size(); ++i) {
// Service the OS message queue between builds so the window doesn't
// get flagged as "not responding" while loading many navigation grids.
SDL_PumpEvents();
navigationMaps[i].build(paths[i], CONST_ZIP_FILE);
}
activeNavigationIndex =
savedActiveNavigationIndex >= 0 &&
savedActiveNavigationIndex < static_cast<int>(navigationMaps.size())
? savedActiveNavigationIndex
: 0;
}
void LocationState::save(nlohmann::json& out) const
{
out["cameraAzimuth"] = cameraAzimuth;
@@ -9,99 +79,14 @@ void LocationState::save(nlohmann::json& out) const
out["activeNavigationIndex"] = activeNavigationIndex;
out["targetInteractiveObjectIndex"] = targetInteractiveObjectIndex;
out["targetInteractNpcIndex"] = targetInteractNpcIndex;
out["targetTeleportZoneIndex"] = targetTeleportZoneIndex;
out["targetInteractiveObjectId"] = targetInteractiveObjectId;
out["targetInteractNpcId"] = targetInteractNpcId;
out["targetTeleportZoneId"] = targetTeleportZoneId;
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}
out["taxi"] = {
{"car", saveCar(taxiCar)},
{"arrivedCallbackCalled", taxiArrivedCallbackCalled}
};
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)
@@ -113,122 +98,14 @@ void LocationState::load(const nlohmann::json& in)
activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex);
targetInteractiveObjectIndex = in.value("targetInteractiveObjectIndex", -1);
targetInteractNpcIndex = in.value("targetInteractNpcIndex", -1);
targetTeleportZoneIndex = in.value("targetTeleportZoneIndex", -1);
isDarklands = in.value("isDarklands", false);
isNight = in.value("isNight", false);
isDawn = in.value("isDawn", false);
//logger() << "LocationState::load step 2" << std::endl;
nlohmann::json taxiJson = in["taxi"];
taxiArrivedCallbackCalled = taxiJson.value("taxiArrivedCallbackCalled", false);
//----- Taxi State ----
nlohmann::json taxiCarJson = taxiJson["taxiCar"];
taxiCar.position = Eigen::Vector3f(
taxiCarJson.value("positionX", 0.f),
taxiCarJson.value("positionY", 0.f),
taxiCarJson.value("positionZ", 0.f)
);
//logger() << "LocationState::load step 3" << std::endl;
taxiCar.rotation = taxiCarJson.value("rotation", 0.f);
taxiCar.velocity = taxiCarJson.value("velocity", 0.f);
taxiCar.steeringAngle = taxiCarJson.value("steeringAngle", 0.f);
taxiCar.mode = static_cast<NpcCar::Mode>(taxiCarJson.value("mode", 0));
//logger() << "LocationState::load step 4" << std::endl;
taxiCar.waypoints.clear();
if (taxiCarJson.contains("waypoints") && taxiCarJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarJson["waypoints"].size()); ++i)
taxiCar.waypoints.push_back(Eigen::Vector3f(
taxiCarJson["waypoints"][i].value("x", 0.f),
taxiCarJson["waypoints"][i].value("y", 0.f),
taxiCarJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 5" << std::endl;
taxiCar.currentWaypoint = taxiCarJson.value("currentWaypoint", 0);
taxiCar.waypointReachRadius = taxiCarJson.value("waypointReachRadius", 3.f);
taxiCar.wheelAngle = taxiCarJson.value("wheelAngle", 0.f);
//--------- Taxi initial state -----
//logger() << "LocationState::load step 6" << std::endl;
nlohmann::json taxiCarInitialStateJson = taxiJson["taxiCarInitialState"];
taxiCarInitialState.position = Eigen::Vector3f(
taxiCarInitialStateJson.value("positionX", 0.f),
taxiCarInitialStateJson.value("positionY", 0.f),
taxiCarInitialStateJson.value("positionZ", 0.f)
);
taxiCarInitialState.rotation = taxiCarInitialStateJson.value("rotation", 0.f);
taxiCarInitialState.velocity = taxiCarInitialStateJson.value("velocity", 0.f);
taxiCarInitialState.steeringAngle = taxiCarInitialStateJson.value("steeringAngle", 0.f);
taxiCarInitialState.mode = static_cast<NpcCar::Mode>(taxiCarInitialStateJson.value("mode", 0));
//logger() << "LocationState::load step 7" << std::endl;
taxiCarInitialState.waypoints.clear();
if (taxiCarInitialStateJson.contains("waypoints") && taxiCarInitialStateJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarInitialStateJson["waypoints"].size()); ++i)
taxiCarInitialState.waypoints.push_back(Eigen::Vector3f(
taxiCarInitialStateJson["waypoints"][i].value("x", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("y", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 8" << std::endl;
taxiCarInitialState.currentWaypoint = taxiCarInitialStateJson.value("currentWaypoint", 0);
taxiCarInitialState.waypointReachRadius = taxiCarInitialStateJson.value("waypointReachRadius", 3.f);
taxiCarInitialState.wheelAngle = taxiCarInitialStateJson.value("wheelAngle", 0.f);
taxiCarWaypoints.clear();
if (taxiJson.contains("taxiDefaultWaypoints") && taxiJson["taxiDefaultWaypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiJson["taxiDefaultWaypoints"].size()); ++i)
taxiCarWaypoints.push_back(Eigen::Vector3f(
taxiJson["taxiDefaultWaypoints"][i].value("x", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("y", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 9" << std::endl;
taxiCarTriggerPosition = Eigen::Vector3f(
taxiJson.value("taxiCarTriggerPositionX", 0.f),
taxiJson.value("taxiCarTriggerPositionY", 0.f),
taxiJson.value("taxiCarTriggerPositionZ", 0.f)
);
//logger() << "LocationState::load step 10" << std::endl;
targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string());
targetInteractNpcId = in.value("targetInteractNpcId", std::string());
targetTeleportZoneId = in.value("targetTeleportZoneId", std::string());
const auto& taxi = in.at("taxi");
loadCar(taxiCar, taxi.at("car"));
taxiArrivedCallbackCalled =
taxi.at("arrivedCallbackCalled").get<bool>();
}
} // namespace FRG
+12 -5
View File
@@ -2,10 +2,18 @@
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
#include "NpcCar.h"
#include "navigation/PathFinder.h"
#include <string>
namespace FRG {
struct LocationState : 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);
// ---- Camera ----
float cameraAzimuth = -2.35f;
float cameraInclination = 1.1036f;
@@ -13,11 +21,10 @@ struct LocationState : public ISaveable {
// ---- Navigation ----
int activeNavigationIndex = 0;
// ---- Walk-to targets (-1 = none) ----
// Mirrors CharacterState's kNoTarget convention; raw pointers are not serialisable.
int targetInteractiveObjectIndex = -1;
int targetInteractNpcIndex = -1;
int targetTeleportZoneIndex = -1;
// ---- Walk-to targets (empty = none) ----
std::string targetInteractiveObjectId;
std::string targetInteractNpcId;
std::string targetTeleportZoneId;
// ---- World-mode flags (synced from Game after each transition) ----
bool isDarklands = false;
+131 -114
View File
@@ -201,9 +201,9 @@ namespace FRG {
}
#endif
MenuManager::MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer) :
MenuManager::MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer) :
renderer(iRenderer),
gameState_(gameState),
server_(server),
audioPlayer_(audioPlayer)
{
//Localization
@@ -338,9 +338,7 @@ namespace FRG {
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
}
void MenuManager::setup(Inventory& inv, const std::string& zipFile) {
inventory = &inv;
void MenuManager::setup(const std::string& zipFile) {
loadUiRoots(zipFile);
texObjectiveCompleted_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_completed.png", zipFile, true);
@@ -360,19 +358,6 @@ namespace FRG {
languageLoadingRu = renderer.textureManager.LoadFromPng("resources/loading_land003_ru.png", zipFile);
languageLoadingEn = renderer.textureManager.LoadFromPng("resources/loading_land003_en.png", zipFile);
gameState_.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestNew, q->definition.title);
};
gameState_.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestCompleted, q->definition.title);
};
gameState_.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestFailed, q->definition.title);
};
const std::string imgDir = "resources/w/ui/img/phone/";
texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true);
texBubbleInLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_top.png", zipFile, true);
@@ -419,9 +404,10 @@ namespace FRG {
}
std::shared_ptr<UiNode> MenuManager::gameplayHudRoot() const {
const std::string& loc = gameState_.currentLocationName;
if (loc == "uni_exterior") return gameState_.isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return gameState_.isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
const auto snapshot = server_.snapshot();
const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
return hudDormRoot; // dorm, or a new game before the location is set
}
@@ -434,21 +420,22 @@ namespace FRG {
}
void MenuManager::refreshHudVisibility() {
if (!inventory) return; // update() already ticks during loading, before setup()
const auto itemIds = server_.snapshot()->inventoryItemIds;
const TutorialStep step = gameState_.tutorialStep;
const bool tutorial = gameState_.isTutorialActive();
const bool hasPhone = inventory->hasItem("phone");
const bool hasJournal = inventory->hasItem("journal");
const bool hasNote = inventory->hasItem("note_spell");
const bool dark = gameState_.isDarklands;
const auto snapshot = server_.snapshot();
const TutorialStep step = snapshot->tutorialStep;
const bool tutorial = snapshot->isTutorialActive();
const bool hasPhone = std::find(itemIds.begin(), itemIds.end(), "phone") != itemIds.end();
const bool hasJournal = std::find(itemIds.begin(), itemIds.end(), "journal") != itemIds.end();
const bool hasNote = std::find(itemIds.begin(), itemIds.end(), "note_spell") != itemIds.end();
const bool dark = snapshot->isDarklands;
auto show = [this](std::initializer_list<const char*> names, bool visible) {
for (const char* name : names) uiManager.setNodeVisible(name, visible);
};
// --- HUD buttons: present while the matching item is in the inventory ---
show({ "inventoryButton" }, inventory->getCount() > 0);
show({ "inventoryButton" }, !itemIds.empty());
show({ "phoneButton" }, hasPhone && !dark);
show({ "journalButton" }, hasJournal);
show({ "darklandsButton" }, hasNote && !dark);
@@ -466,9 +453,9 @@ namespace FRG {
show({ "hint4" }, step == TutorialStep::Zoom);
show({ "hint5" }, step == TutorialStep::PickUpItem);
// ...then the hints for the picked-up items, which can be done in any order.
const bool chatHint = tutorial && !gameState_.tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !gameState_.tutorialJournalScreenOpened;
const bool taxiHint = tutorial && gameState_.tutorialNeedOpenTaxiScreen;
const bool chatHint = tutorial && !snapshot->tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !snapshot->tutorialJournalScreenOpened;
const bool taxiHint = tutorial && snapshot->tutorialNeedOpenTaxiScreen;
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
show({ "hint7", "hint7arrow" }, taxiHint);
@@ -485,7 +472,7 @@ namespace FRG {
}
void MenuManager::showMainMenu() {
gameState_.currentLocationName.clear();
server_.submit(SetCurrentLocationCommand{""});
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -546,7 +533,6 @@ namespace FRG {
}
void MenuManager::showMenuAfterGameCompleted() {
//gameState_.currentLocationName.clear();
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -1060,8 +1046,8 @@ namespace FRG {
steps.push_back([this]() {
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState_.campaignId))
gameState_.questJournal.reloadDefinitionsPreservingState(campaign->questsPath, CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(server_.snapshot()->campaignId))
server_.submit(ReloadQuestDefinitionsCommand{campaign->questsPath, CONST_ZIP_FILE});
});
steps.push_back([this]() {
@@ -1197,7 +1183,7 @@ namespace FRG {
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
const auto& items = inventory->getItems();
const auto items = inventoryItems();
/*logger() << "Item count: " << items.size() << std::endl;
for (int x = 0; x < items.size(); x++)
{
@@ -1236,7 +1222,7 @@ namespace FRG {
void MenuManager::selectInventoryItem(int index) {
logger() << "MenuManager::selectInventoryItem: " << index << std::endl;
const auto& items = inventory->getItems();
const auto items = inventoryItems();
if (index < 0 || index >= static_cast<int>(items.size())) return;
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
@@ -1288,7 +1274,10 @@ namespace FRG {
void MenuManager::openQuestJournal() {
uiState_ = GameUiState::QuestJournal;
gameState_.tutorialJournalScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
tutorial->tutorialPhoneChatScreenOpened, true,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
journalMenuStackDepth_ = 1;
const bool portrait = isPortraitMode();
@@ -1380,9 +1369,10 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
if (gameState_.isNight)
const auto timeState = server_.snapshot();
if (timeState->isNight)
{
if (gameState_.isDawn)
if (timeState->isDawn)
{
uiManager.setNodeVisible("phoneTimeDay", false);
uiManager.setNodeVisible("phoneTimeNight", false);
@@ -1418,51 +1408,67 @@ namespace FRG {
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
gameState_.chatUnread[0] = false;
setChatUnread(0, false);
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
gameState_.chatUnread[1] = false;
setChatUnread(1, false);
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
gameState_.chatUnread[2] = false;
setChatUnread(2, false);
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
}
void MenuManager::refreshChatUnreadIndicators() {
const auto state = server_.snapshot();
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
for (int i = 0; i < 3; ++i) {
uiManager.setNodeVisible(kUnreadNodes[i], gameState_.chatUnread[i]);
if (!gameState_.chatPreviewMsg[i].empty())
uiManager.setText(kMsgNodes[i], gameState_.chatPreviewMsg[i]);
uiManager.setNodeVisible(kUnreadNodes[i], state->chatUnread[i]);
if (!state->chatPreview[i].empty())
uiManager.setText(kMsgNodes[i], state->chatPreview[i]);
}
}
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
if (chatIndex < 0 || chatIndex > 2) return;
gameState_.chatUnread[chatIndex] = unread;
const auto state = server_.snapshot();
std::string preview = state->chatPreview[chatIndex];
if (!previewMsg.empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(previewMsg);
} else if (!unread && !gameState_.chatHistory[chatIndex].empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(gameState_.chatHistory[chatIndex].back().text);
preview = trimChatPreview(previewMsg);
} else if (!unread && !state->chatHistory[chatIndex].empty()) {
preview = trimChatPreview(state->chatHistory[chatIndex].back().text);
}
server_.submit(SetChatCommand{chatIndex, unread, std::move(preview)});
}
void MenuManager::spendMoney(int amount) {
gameState_.money -= amount;
server_.submit(SpendMoneyCommand{amount});
}
std::vector<Item> MenuManager::inventoryItems() const {
std::vector<Item> items;
for (const std::string& id : server_.snapshot()->inventoryItemIds) {
if (const Item* item = ItemRegistry::instance().findById(id)) items.push_back(*item);
}
return items;
}
bool MenuManager::hasInventoryItem(const std::string& id) const {
const auto& ids = server_.snapshot()->inventoryItemIds;
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
void MenuManager::openPhoneBank() {
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneBankRoot : phoneBankRoot);
uiManager.setText("balanceText", formatMoney(gameState_.money));
uiManager.setText("balanceText", formatMoney(server_.snapshot()->money));
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
@@ -1492,13 +1498,15 @@ namespace FRG {
});
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
closePhoneEntirely();
if (gameState_.isNight)
const auto state = server_.snapshot();
if (state->isNight)
{
startDialogueFunc("dialog_video002");
}
else
{
auto day = gameState_.globalInts["day"];
auto dayIt = state->globalInts.find("day");
const int day = dayIt == state->globalInts.end() ? 0 : dayIt->second;
if (day == 0)
{
startDialogueFunc("dialog_video003");
@@ -1513,10 +1521,11 @@ namespace FRG {
}
void MenuManager::openPhoneTaxi() {
if (gameState_.currentLocationName == "uni_interior") {
const std::string location = server_.snapshot()->currentLocationId;
if (location == "uni_interior") {
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
} else if (gameState_.currentLocationName == "uni_exterior") {
} else if (location == "uni_exterior") {
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
} else {
@@ -1539,17 +1548,18 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
gameState_.tutorialNeedOpenTaxiScreen = false;
gameState_.tutorialTaxiHintDone = true;
auto state = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
false, true});
if (gameState_.taxiIsCalled == false)
if (!state->taxiIsCalled)
{
gameState_.taxiIsCalled = true;
if (callTaxiFunc)
{
callTaxiFunc();
}
gameState_.money -= 500;
server_.submit(SetTaxiCalledCommand{true});
server_.submit(StartTaxiCommand{state->currentLocationId});
server_.submit(SetTriggerZoneEnabledCommand{
state->currentLocationId, "taxi_zone001", false});
server_.submit(SpendMoneyCommand{500});
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
@@ -1569,7 +1579,10 @@ namespace FRG {
activeChatIndex_ = chatIndex;
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
phoneChatVisibleBubbles_.clear();
gameState_.tutorialPhoneChatScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
true, tutorial->tutorialJournalScreenOpened,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
uiManager.pushMenuFromSavedRoot(chatRoot);
// Build a dedicated chatUiManager root sized to the FBO chat area
@@ -1616,11 +1629,7 @@ namespace FRG {
}
void MenuManager::returnToPhoneChatList() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
phoneChatVisibleBubbles_.clear();
@@ -1634,11 +1643,7 @@ namespace FRG {
void MenuManager::closePhoneEntirely() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::None;
uiState_ = GameUiState::Gameplay;
@@ -1699,8 +1704,11 @@ namespace FRG {
void MenuManager::tutorialShowTaxiHint()
{
logger() << "tutorialShowTaxiHint" << std::endl;
if (!gameState_.isTutorialActive() || gameState_.tutorialTaxiHintDone) return;
gameState_.tutorialNeedOpenTaxiScreen = true; // cleared when the taxi is ordered
auto state = server_.snapshot();
if (!state->isTutorialActive() || state->tutorialTaxiHintDone) return;
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
true, state->tutorialTaxiHintDone});
refreshHudVisibility();
}
@@ -1723,16 +1731,17 @@ namespace FRG {
void MenuManager::onLocationChanged(const std::string& locationName) {
if (uiState_ != GameUiState::Gameplay) return;
gameState_.currentLocationName = locationName;
server_.submit(SetCurrentLocationCommand{locationName});
const auto state = server_.snapshot();
showGameplayHud();
if (locationName == "uni_exterior" || locationName == "uni_interior") {
if (gameState_.isDarklands)
if (state->isDarklands)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
lastMusicTrack = "music/bishkek fight calm.ogg";
}
else if (gameState_.isNight)
else if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1743,7 +1752,7 @@ namespace FRG {
lastMusicTrack = "music/bishkek univer day.ogg";
}
} else {
if (gameState_.isNight)
if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1759,13 +1768,16 @@ namespace FRG {
void MenuManager::onItemPickedUp(const std::string& itemId) {
// The new item's HUD button appears through refreshHudVisibility().
if (itemId == "phone" || itemId == "journal")
gameState_.completeTutorialStep(TutorialStep::PickUpItem);
server_.submit(CompleteTutorialStepCommand{TutorialStep::PickUpItem});
refreshHudVisibility();
}
void MenuManager::refreshQuestJournalUi() {
visibleQuestIds.clear();
auto quests = gameState_.questJournal.getVisibleQuests();
const auto state = server_.snapshot();
std::vector<const Quest::QuestState*> quests;
quests.reserve(state->quests.size());
for (const auto& quest : state->quests) quests.push_back(&quest);
std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) {
const int pa = questStatusPriority(a->status);
@@ -1840,7 +1852,10 @@ namespace FRG {
}
selectedQuestIndex = index;
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) {
return;
}
@@ -1888,7 +1903,7 @@ namespace FRG {
}
void MenuManager::openPortraitInventoryItem(int index) {
const auto& items = inventory->getItems();
const auto items = inventoryItems();
inventorySelectedIndex_ = index;
inventoryMenuStackDepth_ = 2;
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
@@ -1943,7 +1958,10 @@ namespace FRG {
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) return;
const auto& def = quest->definition;
@@ -2070,7 +2088,8 @@ namespace FRG {
phoneChatVisibleBubbles_.clear();
if (chatIndex < 0 || chatIndex > 2) return;
for (const auto& msg : gameState_.chatHistory[chatIndex]) {
const auto state = server_.snapshot();
for (const auto& msg : state->chatHistory[chatIndex]) {
const bool inc = msg.incoming;
const std::string nodeName = chatUiManager.addChatBubble(
"chatMessagesContainer", msg.text, inc,
@@ -2100,11 +2119,7 @@ namespace FRG {
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
if (activeChatIndex_ < 0) return;
auto& history = gameState_.chatHistory[activeChatIndex_];
if (static_cast<int>(history.size()) >= 5) {
history.erase(history.begin());
}
history.push_back({ text, incoming });
server_.submit(AppendChatMessageCommand{activeChatIndex_, text, incoming});
if (uiState_ != GameUiState::PhoneScreen) return;
@@ -2143,8 +2158,8 @@ namespace FRG {
{
logger() << "MenuManager::setDarklandsMode called" << std::endl;
if (enabled && gameState_.currentLocationName == "uni_interior" && inventory->hasItem("note_spell"))
gameState_.completeTutorialStep(TutorialStep::EnterDarklands);
if (enabled && server_.snapshot()->currentLocationId == "uni_interior" && hasInventoryItem("note_spell"))
server_.submit(CompleteTutorialStepCommand{TutorialStep::EnterDarklands});
if (uiState_ == GameUiState::Gameplay)
showGameplayHud(); // darklands has its own HUD layouts
@@ -2152,30 +2167,31 @@ namespace FRG {
void MenuManager::onPlayerStartedWalking()
{
if (gameState_.currentLocationName == "uni_interior" && gameState_.isDarklands)
gameState_.completeTutorialStep(TutorialStep::DarklandsHealth);
const auto state = server_.snapshot();
if (state->currentLocationId == "uni_interior" && state->isDarklands)
server_.submit(CompleteTutorialStepCommand{TutorialStep::DarklandsHealth});
}
void MenuManager::advanceUniIntDarklandsHud()
{
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
if (gameState_.tutorialStep == TutorialStep::DarklandsHealth
|| gameState_.tutorialStep == TutorialStep::DarklandsExplore)
gameState_.tutorialStep = TutorialStep::DarklandsAttack;
const TutorialStep step = server_.snapshot()->tutorialStep;
if (step == TutorialStep::DarklandsHealth || step == TutorialStep::DarklandsExplore)
server_.submit(SetTutorialStepCommand{TutorialStep::DarklandsAttack});
}
void MenuManager::onEnemyKilledInUniInterior()
{
// Every location's enemies report here; only the university interior drives the tutorial.
if (gameState_.currentLocationName != "uni_interior") return;
const auto state = server_.snapshot();
if (state->currentLocationId != "uni_interior") return;
// The last tutorial step; like the attack hint, also ends the earlier darklands hints.
if (gameState_.tutorialStep >= TutorialStep::DarklandsHealth)
gameState_.tutorialStep = TutorialStep::Completed;
if (state->tutorialStep >= TutorialStep::DarklandsHealth)
server_.submit(SetTutorialStepCommand{TutorialStep::Completed});
}
void MenuManager::updateHealthBar(float hp, float maxHp) {
gameState_.playerHp = hp;
gameState_.playerMaxHp = maxHp;
server_.submit(SetPlayerHealthCommand{hp, maxHp});
applyCurrentHealthBar();
}
@@ -2191,21 +2207,22 @@ namespace FRG {
cutsceneHudActive_ = false;
topUiManager.replaceRoot(nullptr);
if (uiState_ == GameUiState::Gameplay)
onLocationChanged(gameState_.currentLocationName);
onLocationChanged(server_.snapshot()->currentLocationId);
}
void MenuManager::applyCurrentHealthBar() {
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
logger() << "currentPlayerMaxHp_ is " << gameState_.playerMaxHp << std::endl;
if (gameState_.playerMaxHp <= 0.f) return;
const auto state = server_.snapshot();
logger() << "currentPlayerMaxHp_ is " << state->playerMaxHp << std::endl;
if (state->playerMaxHp <= 0.f) return;
logger() << "MenuManager::applyCurrentHealthBar called step 2" << std::endl;
logger() << "currentPlayerHp_ is " << gameState_.playerHp << std::endl;
logger() << "currentPlayerHp_ is " << state->playerHp << std::endl;
const float fraction = std::clamp(gameState_.playerHp / gameState_.playerMaxHp, 0.f, 1.f);
const float fraction = std::clamp(state->playerHp / state->playerMaxHp, 0.f, 1.f);
uiManager.setSliderValue("healthBarFill", fraction);
std::string hpText = std::to_string(static_cast<int>(gameState_.playerHp)) + "/" +
std::to_string(static_cast<int>(gameState_.playerMaxHp));
std::string hpText = std::to_string(static_cast<int>(state->playerHp)) + "/" +
std::to_string(static_cast<int>(state->playerMaxHp));
if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' ');
uiManager.setText("healthValue", hpText);
}
@@ -2447,7 +2464,7 @@ namespace FRG {
openInventory();
const int itemCount = static_cast<int>(inventory->getItems().size());
const int itemCount = static_cast<int>(inventoryItems().size());
if (isPortraitMode() && wasOnItemDetail &&
savedIndex >= 0 && savedIndex < itemCount) {
selectInventoryItem(savedIndex);
+10 -9
View File
@@ -3,8 +3,9 @@
#include "Environment.h"
#include "render/TextureManager.h"
#include "UiManager.h"
#include "Localization.h"
#include "items/Item.h"
#include "GameState.h"
#include "game_server/IGameServer.h"
#include <vector>
#include <deque>
#include <string>
@@ -39,9 +40,9 @@ namespace FRG {
std::unordered_map<std::string,
std::unordered_map<Language, std::string>> localizedLocationName;
MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer);
MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer);
void setup(Inventory& inv, const std::string& zipFile);
void setup(const std::string& zipFile);
void openInventory();
void selectInventoryItem(int index);
@@ -80,7 +81,7 @@ namespace FRG {
void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = "");
void spendMoney(int amount);
int getMoney() const { return gameState_.money; }
int getMoney() const { return server_.snapshot()->money; }
// Starts a new game of the given campaign (see CampaignRegistry).
std::function<void(const std::string& campaignId)> startGameFunc;
@@ -89,7 +90,6 @@ namespace FRG {
std::function<void()> startNightTransitionFunc;
std::function<void(int)> chatOpenCallback;
std::function<void()> skipCutsceneFunc;
std::function<void()> callTaxiFunc;
std::function<void(int)> onSaveGame;
std::function<void(int)> onLoadGame;
std::function<SaveSlotInfo(int)> getSlotInfoFunc;
@@ -172,14 +172,14 @@ namespace FRG {
Renderer& renderer;
private:
GameState& gameState_;
IGameServer& server_;
AudioPlayerAsync& audioPlayer_;
void startNewGame(const std::string& campaignId);
// HUD layout for the current location and darklands mode.
std::shared_ptr<UiNode> gameplayHudRoot() const;
// Single place that decides which HUD buttons and tutorial hints are visible:
// buttons follow the inventory, hints follow gameState_.tutorialStep and the
// buttons follow the inventory snapshot, hints follow the server tutorial state and
// tutorial flags. Runs every frame; names absent from the shown screen are skipped.
void refreshHudVisibility();
void refreshQuestJournalUi();
@@ -249,7 +249,8 @@ namespace FRG {
static constexpr float TOAST_VISIBLE_MS = 2000.0f;
GameUiState uiState_ = GameUiState::Gameplay;
Inventory* inventory = nullptr;
std::vector<Item> inventoryItems() const;
bool hasInventoryItem(const std::string& id) const;
bool isPlayerInCreditsMenu = false;
int inventorySelectedIndex_ = -1;
@@ -336,7 +337,7 @@ namespace FRG {
enum class PhoneSubScreen { None, Main, Bank, Video, MapDorm, MapUni, ChatList, Chat };
PhoneSubScreen currentPhoneSubScreen_ = PhoneSubScreen::None;
// Phone chat UI state (transient — rebuilt from gameState_.chatHistory on open)
// Phone chat UI state (transient — rebuilt from the immutable server snapshot)
struct PhoneChatBubbleInfo {
std::string nodeName;
float height;
+294 -270
View File
@@ -1,5 +1,6 @@
#include "ScriptEngine.h"
#include "ScriptEngine.h"
#include "Game.h"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <unordered_map>
@@ -14,26 +15,11 @@ namespace FRG {
namespace {
// Wraps a sol::protected_function into a std::function<void()>, invoking it and
// logging any Lua-side error to stderr. Shared by every binding that stores a
// callback for later, one-shot invocation (npc_walk_to, move_object, etc.) and by
// rehydrateNamedCallback, which re-wraps a callback looked up by name after load.
std::function<void()> wrapLuaCallback(sol::protected_function fn) {
return [fn]() mutable {
auto result = fn();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[script] callback error: " << err.what() << "\n";
}
};
}
// Reverse-looks-up the global name a Lua function is registered under, by
// comparing raw function pointers via lua_topointer. Returns "" if fn isn't
// (or is no longer) reachable as a plain global — e.g. an anonymous function,
// or one stored only in a local/upvalue. This lets callback-accepting bindings
// remember a callback "by name" for save/load without changing the Lua-facing
// API (scripts still just pass a function reference).
// remember a callback by name while scripts continue to pass function references.
std::string findGlobalFunctionName(lua_State* L, const sol::protected_function& fn) {
if (!fn.valid()) return {};
@@ -58,6 +44,19 @@ namespace FRG {
return found;
}
Character* findNpc(Location* location, const std::string& id) {
for (auto& npc : location->npcs)
if (npc && npc->entityId() == id) return npc.get();
return nullptr;
}
InteractiveObject* findInteractiveObject(
Location* location, const std::string& id) {
for (auto& object : location->interactiveObjects)
if (object.entityId() == id) return &object;
return nullptr;
}
} // anonymous namespace
struct ScriptEngine::Impl {
@@ -65,11 +64,12 @@ namespace FRG {
std::unordered_map<std::string, sol::protected_function> triggerEnterCallbacks;
std::unordered_map<std::string, sol::protected_function> triggerExitCallbacks;
std::unordered_map<std::string, sol::protected_function> cutsceneCompleteCallbacks;
std::unordered_map<int, sol::protected_function> npcBumpedByPlayerCallbacks;
std::unordered_map<int, sol::protected_function> npcBumpsPlayerCallbacks;
std::unordered_map<std::string, sol::protected_function> npcBumpedByPlayerCallbacks;
std::unordered_map<std::string, sol::protected_function> npcBumpsPlayerCallbacks;
std::unordered_map<std::string, int>* globalInts = nullptr;
std::unordered_map<std::string, float>* globalFloats = nullptr;
Quest::QuestJournal* questJournal = nullptr;
sol::protected_function locationInitCallback;
sol::protected_function locationEnterCallback;
sol::protected_function locationExitCallback;
sol::protected_function darklandsEnterCallback;
@@ -110,82 +110,92 @@ namespace FRG {
auto api = lua.create_named_table("game_api");
// npc_walk_to(index, x, y, z [, on_arrived])
// npc_walk_to(npc_id, x, y, z [, on_arrived])
// on_arrived is an optional Lua function called when the NPC reaches the target.
// It can call npc_walk_to again (or anything else) to chain behaviour.
api.set_function("npc_walk_to",
[loc, this_impl = impl.get()](int index, float x, float y, float z, sol::object on_arrived) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_walk_to: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc, this_impl = impl.get()](const std::string& npcId, float x, float y, float z, sol::object on_arrived) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_walk_to: unknown NPC id '" << npcId << "'\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (on_arrived.is<sol::protected_function>()) {
sol::protected_function fn = on_arrived.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] npc_walk_to: on_arrived callback has no global name, will not survive save/load\n";
std::cerr << "[script] npc_walk_to: on_arrived must be a global Lua function\n";
}
npcs[index]->state.homePosition = Eigen::Vector3f(x, 0.f, z);
npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb), std::move(cbName));
loc->updateCharacterState(*npc, [x, z](CharacterState& workingState) {
workingState.homePosition = Eigen::Vector3f(x, 0.f, z);
});
loc->setCharacterTarget(
*npc, Eigen::Vector3f(x, y, z), std::move(cbName));
});
api.set_function("npc_rotate_to",
[loc](int index, float angle) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_rotate_to: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId, float angle) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_rotate_to: unknown NPC id '" << npcId << "'\n";
return;
}
const float rad = angle * static_cast<float>(M_PI) / 180.f;
logger() << "[script] npc_rotate_to: index " << index << " angle " << angle << " degrees (" << rad << " radians)" << std::endl;
npcs[index]->state.targetFacingAngle = rad;
logger() << "[script] npc_rotate_to: " << npcId << " angle " << angle << " degrees (" << rad << " radians)" << std::endl;
loc->updateCharacterState(*npc, [rad](CharacterState& workingState) {
workingState.targetFacingAngle = rad;
});
});
api.set_function("player_rotate_to",
[loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f;
logger() << "[script] player_rotate_to: angle " << angle << " degrees (" << rad << " radians)" << std::endl;
loc->player->state.targetFacingAngle = rad;
loc->updateCharacterState(*loc->player, [rad](CharacterState& workingState) {
workingState.targetFacingAngle = rad;
});
});
api.set_function("player_stop_and_rotate_to_npc",
[loc](int index) {
loc->player->stopAndRotateToTarget(index);
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] player_stop_and_rotate_to_npc: unknown NPC id '"
<< npcId << "'\n";
return;
}
loc->faceCharacter(*loc->player, npc->entityId());
});
api.set_function("npc_stop_and_rotate_to_player",
[loc](int index) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_stop_and_rotate_to_player: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_stop_and_rotate_to_player: unknown NPC id '"
<< npcId << "'\n";
return;
}
npcs[index]->stopAndRotateToTarget(CharacterState::kPlayerIndex);
loc->faceCharacter(*npc, "player");
});
api.set_function("player_walk_to",
[loc, this_impl = impl.get()](float x, float y, float z, sol::object on_arrived) {
std::function<void()> cb;
std::string cbName;
if (on_arrived.is<sol::protected_function>()) {
sol::protected_function fn = on_arrived.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] player_walk_to: on_arrived callback has no global name, will not survive save/load\n";
std::cerr << "[script] player_walk_to: on_arrived must be a global Lua function\n";
}
loc->player->state.homePosition = Eigen::Vector3f(x, 0.f, z);
loc->player->setTarget(Eigen::Vector3f(x, y, z), std::move(cb), std::move(cbName));
loc->updateCharacterState(*loc->player, [x, z](CharacterState& workingState) {
workingState.homePosition = Eigen::Vector3f(x, 0.f, z);
});
loc->setCharacterTarget(
*loc->player, Eigen::Vector3f(x, y, z), std::move(cbName));
});
@@ -225,50 +235,44 @@ namespace FRG {
inventory->removeItem(id);
});
// deactivate_interactive_object(object_name)
api.set_function("deactivate_interactive_object", [loc](const std::string& objectName) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.isActive = false;
logger() << "[script] deactivate_interactive_object: " << objectName << std::endl;
return;
}
api.set_function("deactivate_interactive_object", [loc](const std::string& objectId) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.isActive = false;
logger() << "[script] deactivate_interactive_object: " << objectId << std::endl;
return;
}
std::cerr << "[script] deactivate_interactive_object: not found: " << objectName << std::endl;
std::cerr << "[script] deactivate_interactive_object: unknown object id: " << objectId << std::endl;
});
// activate_interactive_object(object_name)
api.set_function("activate_interactive_object", [loc](const std::string& objectName) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.isActive = true;
logger() << "[script] activate_interactive_object: " << objectName << std::endl;
return;
}
api.set_function("activate_interactive_object", [loc](const std::string& objectId) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.isActive = true;
logger() << "[script] activate_interactive_object: " << objectId << std::endl;
return;
}
std::cerr << "[script] activate_interactive_object: not found: " << objectName << std::endl;
std::cerr << "[script] activate_interactive_object: unknown object id: " << objectId << std::endl;
});
api.set_function("set_object_rotation", [loc](const std::string& objectName, float value) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.rotationY = value * static_cast<float>(M_PI) / 180.f;
logger() << "[script] set_object_rotation: " << objectName << " " << value << std::endl;
return;
}
api.set_function("set_object_rotation", [loc](const std::string& objectId, float value) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.rotationY = value * static_cast<float>(M_PI) / 180.f;
logger() << "[script] set_object_rotation: " << objectId << " " << value << std::endl;
return;
}
std::cerr << "[script] set_object_rotation: not found: " << objectName << std::endl;
std::cerr << "[script] set_object_rotation: unknown object id: " << objectId << std::endl;
});
api.set_function("set_object_alpha", [loc](const std::string& objectName, float value) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.alpha = value;
logger() << "[script] set_object_alpha: " <<objectName << " " << value << std::endl;
return;
}
api.set_function("set_object_alpha", [loc](const std::string& objectId, float value) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.alpha = value;
logger() << "[script] set_object_alpha: " << objectId << " " << value << std::endl;
return;
}
std::cerr << "[script] set_object_alpha: not found: " << objectName << std::endl;
std::cerr << "[script] set_object_alpha: unknown object id: " << objectId << std::endl;
});
// get_inventory_count()
@@ -332,7 +336,15 @@ namespace FRG {
// on_enter and on_exit are optional Lua functions (pass nil to omit).
// Called when the player enters or exits the named trigger zone.
api.set_function("set_trigger_zone_callbacks",
[this_impl = impl.get()](const std::string& zoneId, sol::object onEnter, sol::object onExit) {
[loc, this_impl = impl.get()](const std::string& zoneId, sol::object onEnter, sol::object onExit) {
const bool exists = std::any_of(
loc->triggerZones.begin(), loc->triggerZones.end(),
[&zoneId](const TriggerZone& zone) { return zone.id == zoneId; });
if (!exists) {
std::cerr << "[script] set_trigger_zone_callbacks: unknown zone id '"
<< zoneId << "'\n";
return;
}
if (onEnter.is<sol::protected_function>())
this_impl->triggerEnterCallbacks[zoneId] = onEnter.as<sol::protected_function>();
if (onExit.is<sol::protected_function>())
@@ -386,11 +398,15 @@ namespace FRG {
loc->requestAdvanceDarklandsHud();
});
// set_location_callbacks(on_enter, on_exit)
// set_location_callbacks(on_init, on_enter, on_exit)
// on_init() is called only when this location has no retained state.
// on_enter() called once when the player arrives at this location.
// on_exit() called once just before the player leaves this location.
api.set_function("set_location_callbacks",
[this_impl = impl.get()](sol::object onEnter, sol::object onExit) {
[this_impl = impl.get()](
sol::object onInit, sol::object onEnter, sol::object onExit) {
if (onInit.is<sol::protected_function>())
this_impl->locationInitCallback = onInit.as<sol::protected_function>();
if (onEnter.is<sol::protected_function>())
this_impl->locationEnterCallback = onEnter.as<sol::protected_function>();
if (onExit.is<sol::protected_function>())
@@ -415,11 +431,11 @@ namespace FRG {
api.set_function("get_player_x",
[loc]() -> float {
return loc->player ? loc->player->state.position.x() : 0.0f;
return loc->player ? loc->player->characterState.position.x() : 0.0f;
});
api.set_function("get_player_z",
[loc]() -> float {
return loc->player ? loc->player->state.position.z() : 0.0f;
return loc->player ? loc->player->characterState.position.z() : 0.0f;
});
// set_player_hp(value) — sets the player's current HP directly.
@@ -427,12 +443,14 @@ namespace FRG {
[loc](float value) {
if (loc->player)
{
loc->player->state.setHp(value);
if (loc->player->state.currentState == AnimationState::ACTION_TO_DEATH || loc->player->state.currentState == AnimationState::DEATH_IDLE)
{
loc->player->state.currentState = AnimationState::STAND;
loc->player->state.resetAnim = true;
}
loc->setCharacterHp(*loc->player, value);
loc->updateCharacterState(*loc->player, [](CharacterState& workingState) {
if (workingState.currentState == AnimationState::ACTION_TO_DEATH ||
workingState.currentState == AnimationState::DEATH_IDLE) {
workingState.currentState = AnimationState::STAND;
workingState.resetAnim = true;
}
});
}
});
// get_player_hp() → float (0 if no player)
@@ -537,246 +555,253 @@ namespace FRG {
});
api.set_function("set_trigger_zone_enabled",
[loc](int index, bool value) {
auto& triggerZones = loc->triggerZones;
if (index < 0 || index >= static_cast<int>(triggerZones.size())) {
std::cerr << "[script] set_trigger_zone_enabled: index " << index << " out of range\n";
[loc](const std::string& zoneId, bool value) {
for (auto& zone : loc->triggerZones) {
if (zone.id != zoneId) continue;
zone.enabled = value;
return;
}
triggerZones[index].enabled = value;
std::cerr << "[script] set_trigger_zone_enabled: unknown zone id '"
<< zoneId << "'\n";
});
// set_teleport_active(index, active) — activates or deactivates a teleport zone by index.
// set_teleport_active(zone_id, active)
// Swaps the particle effect texture to match the new state.
api.set_function("set_teleport_active",
[loc](int index, bool value) {
auto& zones = loc->teleportZones;
if (index < 0 || index >= static_cast<int>(zones.size())) {
std::cerr << "[script] set_teleport_active: index " << index << " out of range\n";
[loc](const std::string& zoneId, bool value) {
for (auto& zone : loc->teleportZones) {
if (zone.id != zoneId) continue;
zone.setActive(value);
return;
}
zones[index].setActive(value);
std::cerr << "[script] set_teleport_active: unknown zone id '"
<< zoneId << "'\n";
});
// is_teleport_active(index) → bool
// is_teleport_active(zone_id) → bool
api.set_function("is_teleport_active",
[loc](int index) -> bool {
auto& zones = loc->teleportZones;
if (index < 0 || index >= static_cast<int>(zones.size())) {
std::cerr << "[script] is_teleport_active: index " << index << " out of range\n";
return false;
}
return zones[index].active;
[loc](const std::string& zoneId) -> bool {
for (const auto& zone : loc->teleportZones)
if (zone.id == zoneId) return zone.active;
std::cerr << "[script] is_teleport_active: unknown zone id '"
<< zoneId << "'\n";
return false;
});
// npc_set_hp(index, value) — sets an NPC's current HP directly.
// npc_set_hp(npc_id, value)
api.set_function("npc_set_hp",
[loc](int index, float value) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_hp: index " << index << " out of range\n";
[loc](const std::string& npcId, float value) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_hp: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->setHp(value);
loc->setCharacterHp(*npc, value);
});
// npc_set_position(index, x, y, z) — teleports an NPC instantly, no walking.
// npc_set_position(npc_id, x, y, z)
api.set_function("npc_set_position",
[loc](int index, float x, float y, float z) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_position: index " << index << " out of range\n";
[loc](const std::string& npcId, float x, float y, float z) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_position: unknown NPC id '" << npcId << "'\n";
return;
}
Eigen::Vector3f pos(x, y, z);
npcs[index]->state.position = pos;
npcs[index]->state.homePosition = pos;
npcs[index]->setTarget(pos);
loc->updateCharacterState(*npc, [&pos](CharacterState& workingState) {
workingState.position = pos;
workingState.homePosition = pos;
});
loc->setCharacterTarget(*npc, pos);
});
// npc_set_rotation(index, angle) — sets NPC facing angle around Y axis (degrees).
// npc_set_rotation(npc_id, angle)
api.set_function("npc_set_rotation",
[loc](int index, float angle) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_rotation: index " << index << " out of range\n";
[loc](const std::string& npcId, float angle) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_rotation: unknown NPC id '" << npcId << "'\n";
return;
}
const float rad = angle * static_cast<float>(M_PI) / 180.f;
npcs[index]->state.facingAngle = rad;
npcs[index]->state.targetFacingAngle = rad;
loc->updateCharacterState(*npc, [rad](CharacterState& workingState) {
workingState.facingAngle = rad;
workingState.targetFacingAngle = rad;
});
});
// set_npc_enabled(index, enabled)
// set_npc_enabled(npc_id, enabled)
api.set_function("set_npc_enabled",
[loc](int index, bool value) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] set_npc_enabled: index " << index << " out of range\n";
[loc](const std::string& npcId, bool value) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] set_npc_enabled: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->state.enabled = value;
npcs[index]->state.attack = 0;
npcs[index]->state.attack_cooldown = 0;
npcs[index]->state.currentState = AnimationState::STAND;
if (npcs[index]->state.canAttack)
{
npcs[index]->state.attackTargetIndex = CharacterState::kPlayerIndex;
}
npcs[index]->state.battle_state = 0;
loc->updateCharacterState(*npc, [value](CharacterState& workingState) {
workingState.enabled = value;
workingState.attack = 0;
workingState.attack_cooldown = 0;
workingState.currentState = AnimationState::STAND;
if (workingState.canAttack)
workingState.attackTargetId = "player";
workingState.battle_state = 0;
});
});
api.set_function("player_set_position",
[loc](float x, float y, float z) {
Eigen::Vector3f pos(x, y, z);
loc->player->state.position = pos;
loc->player->state.homePosition = pos;
loc->player->setTarget(pos);
loc->updateCharacterState(*loc->player, [&pos](CharacterState& workingState) {
workingState.position = pos;
workingState.homePosition = pos;
});
loc->setCharacterTarget(*loc->player, pos);
});
api.set_function("player_set_rotation",
[loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f;
loc->player->state.facingAngle = rad;
loc->player->state.targetFacingAngle = rad;
loc->updateCharacterState(*loc->player, [rad](CharacterState& workingState) {
workingState.facingAngle = rad;
workingState.targetFacingAngle = rad;
});
});
// move_object(name, x, y, z, duration_sec [, on_complete])
api.set_function("move_object",
[loc, this_impl = impl.get()](const std::string& name, float x, float y, float z,
[loc, this_impl = impl.get()](const std::string& objectId, float x, float y, float z,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] move_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] move_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.moveTo(Eigen::Vector3f(x, y, z), durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] move_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] move_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] move_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] move_object: on_complete must be a global Lua function\n";
}
object->state.moveTo(
Eigen::Vector3f(x, y, z), durationSec, std::move(cbName));
});
// rotate_object(name, angle_deg, duration_sec [, on_complete])
api.set_function("rotate_object",
[loc, this_impl = impl.get()](const std::string& name, float angleDeg,
[loc, this_impl = impl.get()](const std::string& objectId, float angleDeg,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] rotate_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] rotate_object: on_complete callback has no global name, will not survive save/load\n";
}
const float angleRad = angleDeg * static_cast<float>(M_PI) / 180.f;
intObj.rotateTo(intObj.state.rotationY + angleRad, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] rotate_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] rotate_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] rotate_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] rotate_object: on_complete must be a global Lua function\n";
}
const float angleRad = angleDeg * static_cast<float>(M_PI) / 180.f;
object->state.rotateTo(
object->state.rotationY + angleRad, durationSec, std::move(cbName));
});
// fade_object(name, target_alpha, duration_sec [, on_complete])
api.set_function("fade_object",
[loc, this_impl = impl.get()](const std::string& name, float targetAlpha,
[loc, this_impl = impl.get()](const std::string& objectId, float targetAlpha,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] fade_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] fade_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.fadeTo(targetAlpha, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] fade_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] fade_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] fade_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] fade_object: on_complete must be a global Lua function\n";
}
object->state.fadeTo(targetAlpha, durationSec, std::move(cbName));
});
// scale_object(name, target_scale, duration_sec [, on_complete])
api.set_function("scale_object",
[loc, this_impl = impl.get()](const std::string& name, float targetScale,
[loc, this_impl = impl.get()](const std::string& objectId, float targetScale,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] scale_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] scale_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.scaleTo(targetScale, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] scale_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] scale_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] scale_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] scale_object: on_complete must be a global Lua function\n";
}
object->state.scaleTo(targetScale, durationSec, std::move(cbName));
});
api.set_function("resetPlayerAfterDeath",
[loc]() {
if (loc->player) loc->player->resetPlayerAfterDeath();
if (loc->player) loc->resetCharacterAfterDeath(*loc->player);
});
// player_stop() — cancels the player's current walk target and stops them immediately.
api.set_function("player_stop",
[loc]() {
if (loc->player) loc->player->stopInPlace();
if (loc->player) loc->stopCharacter(*loc->player);
});
// npc_stop(index) — cancels an NPC's current walk target and stops them immediately.
// npc_stop(npc_id)
api.set_function("npc_stop",
[loc](int index) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_stop: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_stop: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->stopInPlace();
loc->stopCharacter(*npc);
});
// set_npc_bump_callbacks(npc_index, on_player_bumps_npc, on_npc_bumps_player)
// set_npc_bump_callbacks(npc_id, on_player_bumps_npc, on_npc_bumps_player)
// Both callbacks are optional (pass nil to omit).
// on_player_bumps_npc() — player was moving and walked into the NPC.
// on_npc_bumps_player() — the NPC was moving and walked into the standing player.
// Each callback fires at most once every 5 seconds per NPC (cooldown managed by Location).
api.set_function("set_npc_bump_callbacks",
[this_impl = impl.get()](int index, sol::object onPlayerBumpsNpc, sol::object onNpcBumpsPlayer) {
[loc, this_impl = impl.get()](const std::string& npcId, sol::object onPlayerBumpsNpc, sol::object onNpcBumpsPlayer) {
if (!findNpc(loc, npcId)) {
std::cerr << "[script] set_npc_bump_callbacks: unknown NPC id '"
<< npcId << "'\n";
return;
}
if (onPlayerBumpsNpc.is<sol::protected_function>())
this_impl->npcBumpedByPlayerCallbacks[index] = onPlayerBumpsNpc.as<sol::protected_function>();
this_impl->npcBumpedByPlayerCallbacks[npcId] = onPlayerBumpsNpc.as<sol::protected_function>();
if (onNpcBumpsPlayer.is<sol::protected_function>())
this_impl->npcBumpsPlayerCallbacks[index] = onNpcBumpsPlayer.as<sol::protected_function>();
this_impl->npcBumpsPlayerCallbacks[npcId] = onNpcBumpsPlayer.as<sol::protected_function>();
});
// quest_unlock(quest_id)
@@ -896,7 +921,7 @@ namespace FRG {
#endif
}
void ScriptEngine::callNpcInteractCallback(int npcIndex) {
void ScriptEngine::callNpcInteractCallback(const std::string& npcId) {
if (!impl) {
std::cerr << "[SCRIPT] Engine not initialized!" << std::endl;
return;
@@ -904,13 +929,13 @@ namespace FRG {
sol::state& lua = impl->lua;
sol::function fn = lua["on_npc_interact"];
if (fn.valid()) {
auto result = fn(npcIndex);
auto result = fn(npcId);
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_npc_interact error: " << err.what() << "\n";
}
else {
logger() << "[SCRIPT] on_npc_interact called with index " << npcIndex << std::endl;
logger() << "[SCRIPT] on_npc_interact called with id " << npcId << std::endl;
}
}
else {
@@ -926,26 +951,26 @@ namespace FRG {
}
}*/
void ScriptEngine::callActivateFunction(const std::string& functionName) {
void ScriptEngine::callFunction(const std::string& functionName) {
if (!impl) {
throw std::runtime_error("[SCRIPT] Engine not initialized!");
}
if (functionName.empty()) {
throw std::runtime_error("[SCRIPT] Activate function name is empty!");
throw std::runtime_error("[SCRIPT] Function name is empty!");
}
sol::state& lua = impl->lua;
logger() << "[SCRIPT] Looking for activate function: " << functionName << std::endl;
logger() << "[SCRIPT] Looking for function: " << functionName << std::endl;
sol::function activateFunc = lua[functionName];
sol::protected_function function = lua[functionName];
if (!activateFunc.valid()) {
if (!function.valid()) {
throw std::runtime_error("[SCRIPT] Lua function not found: " + functionName);
}
logger() << "[SCRIPT] Found function! Calling: " << functionName << std::endl;
auto result = activateFunc();
auto result = function();
if (!result.valid()) {
sol::error err = result;
@@ -955,6 +980,10 @@ namespace FRG {
logger() << "[SCRIPT] Function executed successfully!" << std::endl;
}
void ScriptEngine::callActivateFunction(const std::string& functionName) {
callFunction(functionName);
}
void ScriptEngine::callItemPickupCallback(const std::string& objectName) {
if (!impl) {
std::cerr << "[SCRIPT] impl is null!" << std::endl;
@@ -1016,6 +1045,15 @@ namespace FRG {
if (impl) impl->questJournal = journal;
}
void ScriptEngine::callLocationInitCallback() {
if (!impl || !impl->locationInitCallback.valid()) return;
auto result = impl->locationInitCallback();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] location init callback error: " << err.what() << "\n";
}
}
void ScriptEngine::callLocationEnterCallback() {
if (!impl || !impl->locationEnterCallback.valid()) return;
auto result = impl->locationEnterCallback();
@@ -1082,14 +1120,14 @@ namespace FRG {
}
}
void ScriptEngine::callNpcBumpedByPlayerCallback(int npcIndex) {
void ScriptEngine::callNpcBumpedByPlayerCallback(const std::string& npcId) {
if (!impl) return;
auto it = impl->npcBumpedByPlayerCallbacks.find(npcIndex);
auto it = impl->npcBumpedByPlayerCallbacks.find(npcId);
if (it == impl->npcBumpedByPlayerCallbacks.end()) return;
auto result = it->second();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_player_bumps_npc error for NPC " << npcIndex << ": " << err.what() << "\n";
std::cerr << "[SCRIPT] on_player_bumps_npc error for NPC " << npcId << ": " << err.what() << "\n";
}
}
@@ -1105,14 +1143,14 @@ namespace FRG {
}
}
void ScriptEngine::callNpcBumpsPlayerCallback(int npcIndex) {
void ScriptEngine::callNpcBumpsPlayerCallback(const std::string& npcId) {
if (!impl) return;
auto it = impl->npcBumpsPlayerCallbacks.find(npcIndex);
auto it = impl->npcBumpsPlayerCallbacks.find(npcId);
if (it == impl->npcBumpsPlayerCallbacks.end()) return;
auto result = it->second();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_npc_bumps_player error for NPC " << npcIndex << ": " << err.what() << "\n";
std::cerr << "[SCRIPT] on_npc_bumps_player error for NPC " << npcId << ": " << err.what() << "\n";
}
}
@@ -1177,18 +1215,4 @@ namespace FRG {
}
}
void ScriptEngine::rehydrateNamedCallback(const std::string& name, std::function<void()>& outCallback) const
{
outCallback = nullptr;
if (!impl || name.empty()) return;
sol::protected_function fn = impl->lua[name];
if (!fn.valid()) {
std::cerr << "[SCRIPT] rehydrateNamedCallback: global function '" << name
<< "' not found (script changed since save?)\n";
return;
}
outCallback = wrapLuaCallback(fn);
}
} // namespace FRG
+8 -16
View File
@@ -2,7 +2,6 @@
#include <string>
#include <memory>
#include <unordered_map>
#include <functional>
#include "quest/QuestJournal.h"
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
@@ -13,7 +12,7 @@ namespace FRG {
class Location;
class Inventory;
class ScriptEngine : public ISaveable {
class ScriptEngine {
public:
ScriptEngine();
~ScriptEngine();
@@ -25,23 +24,24 @@ public:
//void runScript(const std::string& path);
void callItemPickupCallback(const std::string& objectName);
void callFunction(const std::string& functionName);
void callActivateFunction(const std::string& functionName);
void callNpcInteractCallback(int npcIndex);
void callNpcInteractCallback(const std::string& npcId);
void callTriggerEnterCallback(const std::string& zoneId);
void callTriggerExitCallback(const std::string& zoneId);
// Fired when the player (moving) physically bumps into a standing NPC.
void callNpcBumpedByPlayerCallback(int npcIndex);
void callNpcBumpedByPlayerCallback(const std::string& npcId);
// Fired when a moving NPC physically bumps into the standing player.
void callNpcBumpsPlayerCallback(int npcIndex);
void 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 callLocationExitCallback();
@@ -63,16 +63,8 @@ public:
// has already run and registered all callbacks.
void loadScriptGlobals(const nlohmann::json& in);
// Re-wires an in-flight, one-shot callback (NPC/player walk-arrival, object
// animation-complete) that was serialized by name rather than by value.
// Looks up `name` as a global Lua function and assigns a wrapped invoker to
// outCallback; leaves outCallback empty and logs an error if not found.
// Call after init() (the script must already have run) and after the owning
// state (CharacterState / InteractiveObjectState) has been loaded.
void rehydrateNamedCallback(const std::string& name, std::function<void()>& outCallback) const;
void save(nlohmann::json& out) const override { saveScriptGlobals(out); }
void load(const nlohmann::json& in) override { loadScriptGlobals(in); }
void save(nlohmann::json& out) const { saveScriptGlobals(out); }
void load(const nlohmann::json& in) { loadScriptGlobals(in); }
private:
struct Impl;
+3 -3
View File
@@ -51,13 +51,13 @@ namespace FRG {
std::string shaderProgramName;
// Sparks spawn within this radius of each emission point. 0 means
// "spawn exactly at the point" (legacy behavior).
// "spawn exactly at the point" (old behavior).
float emissionRadius;
bool configured;
// Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity,
// particles are rendered as axis-aligned quads in world XY and sorted by
// world Z (legacy behavior). Otherwise quads are billboarded toward the
// world Z (old behavior). Otherwise quads are billboarded toward the
// camera using its world-space right/up axes, and particles are sorted
// back-to-front in eye space for correct alpha blending.
void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix);
@@ -106,7 +106,7 @@ namespace FRG {
// Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO.
// Pass the current camera view matrix (world → eye) to billboard sparks
// toward the camera and sort them back-to-front. Identity (default)
// keeps the legacy non-billboarded behavior.
// keeps the old non-billboarded behavior.
void prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix = Eigen::Matrix4f::Identity());
void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight);
+2 -2
View File
@@ -287,10 +287,10 @@ namespace FRG {
float x1, u1;
if (fillMode) {
const float clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped <= 0.0f) {
/*if (clamped <= 0.0f) {
trackMesh.RefreshVBO();
return;
}
}*/
x1 = rect.x + rect.w * clamped;
u1 = clamped;
} else {
+3 -3
View File
@@ -14,7 +14,7 @@
namespace FRG::Dialogue {
class DialogueSystem : public FRG::ISaveable {
class DialogueSystem {
public:
bool init(Renderer& renderer, const std::string& zipFile = "");
@@ -48,8 +48,8 @@ public:
void saveDialogueState(nlohmann::json& out) const { dialogueRuntime.save(out); }
void loadDialogueState(const nlohmann::json& in) { dialogueRuntime.load(in); }
void save(nlohmann::json& out) const override { saveDialogueState(out); }
void load(const nlohmann::json& in) override { loadDialogueState(in); }
void save(nlohmann::json& out) const { saveDialogueState(out); }
void load(const nlohmann::json& in) { loadDialogueState(in); }
bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); }
bool isDialogueActive() const { return dialogueRuntime.isActive(); }
+227
View File
@@ -0,0 +1,227 @@
#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;
LocationState 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 AddInventoryItemCommand { std::string itemId; };
struct RemoveInventoryItemCommand { std::string itemId; };
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,
AddInventoryItemCommand,
RemoveInventoryItemCommand,
SetGlobalIntCommand,
SetGlobalFloatCommand,
SetCharacterTransformCommand,
SetCharacterTargetCommand,
ClearCharacterAttackTargetCommand,
SetCharacterDestinationCommand,
StopCharacterCommand,
FaceCharacterCommand,
ReplanCharacterCommand,
ResetCharacterAfterDeathCommand,
TryCharacterAttackComboCommand,
SetCharacterHealthCommand,
SetCharacterAnimationMetadataCommand,
DamageCharacterCommand,
CommitCharacterStateCommand,
SetInteractiveObjectActiveCommand,
SetTriggerZoneEnabledCommand,
ReloadQuestDefinitionsCommand>;
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;
virtual void submit(const GameCommand& command) = 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
+295
View File
@@ -0,0 +1,295 @@
#include "InternalGameServer.h"
#include "../Location.h"
#include <algorithm>
#include <type_traits>
namespace FRG {
InternalGameServer::InternalGameServer() = default;
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)
{
std::visit([this](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, AddInventoryItemCommand>) {
state_.inventory.addItem(value.itemId);
} else if constexpr (std::is_same_v<T, RemoveInventoryItemCommand>) {
state_.inventory.removeItem(value.itemId);
} 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;
auto runtime = runtimeLocations_.find(value.locationId);
if (runtime != runtimeLocations_.end() && runtime->second) {
for (auto& zone : runtime->second->triggerZones)
if (zone.id == value.zoneId) zone.enabled = value.enabled;
}
}
} else if constexpr (std::is_same_v<T, ReloadQuestDefinitionsCommand>) {
state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile);
}
}, command);
markHostMutation();
}
void InternalGameServer::update(std::int64_t deltaMs)
{
auto runtime = runtimeLocations_.find(state_.currentLocationName);
if (runtime == runtimeLocations_.end() || !runtime->second) return;
auto stored = state_.locations.find(state_.currentLocationName);
if (stored == state_.locations.end()) return;
const bool interactiveObjectsLocked =
stored->second.lockInteractiveObjectsDuringTutorial &&
state_.tutorialStep < TutorialStep::PickUpItem;
LocationSimulationEvents events =
stored->second.update(deltaMs, state_.players, interactiveObjectsLocked);
runtime->second->update(deltaMs, events);
synchronizeRuntimeAuxiliaryState();
}
void InternalGameServer::save(nlohmann::json& out) const
{
const_cast<InternalGameServer*>(this)->synchronizeRuntimeAuxiliaryState();
state_.save(out);
}
void InternalGameServer::load(const nlohmann::json& in)
{
state_.load(in);
applyStateToRuntime();
markHostMutation();
}
void InternalGameServer::bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = state_.locations.find(id);
if (stored != state_.locations.end() && stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
} else {
runtimeLocations_[id]->captureAuxiliaryState(state_.locations[id]);
}
markHostMutation();
}
void InternalGameServer::synchronizeRuntimeAuxiliaryState()
{
for (const auto& [id, location] : runtimeLocations_) {
if (!location) continue;
location->captureAuxiliaryState(state_.locations[id]);
}
markHostMutation();
}
void InternalGameServer::applyStateToRuntime()
{
for (const auto& [id, stored] : state_.locations) {
auto runtime = runtimeLocations_.find(id);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->applyAuxiliaryState(stored);
}
}
void InternalGameServer::markHostMutation()
{
++revision_;
cachedSnapshot_.reset();
}
} // namespace FRG
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "IGameServer.h"
namespace FRG {
class Game;
class Character;
class Location;
// 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:
InternalGameServer();
std::shared_ptr<const GameSnapshot> snapshot() const override;
void submit(const GameCommand& command) override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
private:
friend class Game;
// Transitional host operations are intentionally available only to Game,
// which owns location loading and old-save migration. They are never part of
// IGameServer and therefore cannot leak mutable state to UI/network clients.
GameState& hostState() { return state_; }
const GameState& hostState() const { return state_; }
std::unordered_map<std::string, std::shared_ptr<Location>>& runtimeLocations() { return runtimeLocations_; }
const std::unordered_map<std::string, std::shared_ptr<Location>>& runtimeLocations() const { return runtimeLocations_; }
void bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location);
void synchronizeRuntimeAuxiliaryState();
void applyStateToRuntime();
void markHostMutation();
GameSnapshot buildSnapshot() const;
GameState state_;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
std::uint64_t revision_ = 1;
mutable std::uint64_t cachedRevision_ = 0;
mutable std::shared_ptr<const GameSnapshot> cachedSnapshot_;
};
} // namespace FRG
+67 -12
View File
@@ -8,6 +8,7 @@
#include <Eigen/Geometry>
#include "../Character.h"
#include <SDL.h>
#include <unordered_set>
namespace FRG {
@@ -91,6 +92,9 @@ namespace FRG {
for (const auto& item : j["objects"]) {
InteractiveObjectData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("Interactive object id must not be empty in " + jsonPath);
data.base = parseGameObjectData(item);
data.interactionRadius = item.value("interactionRadius", 2.0f);
data.approachRadius = item.value("approachRadius", data.interactionRadius);
@@ -293,6 +297,7 @@ namespace FRG {
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)
{
std::vector<InteractiveObject> interactiveObjects;
@@ -300,12 +305,35 @@ namespace FRG {
if (jsonPath.empty())
return interactiveObjects;
for (const auto& data : loadInteractiveFromJson(jsonPath, zipPath)) {
const auto definitions = loadInteractiveFromJson(jsonPath, zipPath);
std::unordered_set<std::string> configuredIds;
for (const auto& data : definitions) {
if (!configuredIds.insert(data.id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate interactive object id '" +
data.id + "' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (configuredIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved interactive object id '" + id +
"' is not present in " + jsonPath);
for (const auto& data : definitions) {
SDL_PumpEvents();
logger() << "Loading interactive object: " << data.base.name << std::endl;
try {
InteractiveObject intObj = InteractiveObject::createFromState(
data.toInteractiveObjectState(), renderer, zipPath);
InteractiveObjectState definition = data.toInteractiveObjectState();
auto& state = states[data.id];
if (!state) state = std::make_shared<InteractiveObjectState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
InteractiveObject intObj =
InteractiveObject::createFromState(*state, renderer, zipPath);
intObj.id = data.id;
intObj.loadedObject.name = data.base.name;
if (!data.activateFunctionName.empty())
@@ -338,7 +366,9 @@ namespace FRG {
for (const auto& item : j["npcs"]) {
NpcData data;
data.id = item.value("id", "npc_unknown");
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("NPC id must not be empty in " + jsonPath);
data.name = item.value("name", "Unknown NPC");
data.texturePath = item.value("texturePath", "");
if (item.contains("meshTextures") && item["meshTextures"].is_object()) {
@@ -376,6 +406,7 @@ namespace FRG {
logger() << "Successfully loaded " << npcs.size() << " NPCs from " << jsonPath << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error loading NPCs from JSON: " << e.what() << std::endl;
throw;
}
return npcs;
@@ -385,9 +416,10 @@ namespace FRG {
CharacterState st;
// Identity + game state
st.npcId = id;
st.npcName = name;
st.position = Eigen::Vector3f(positionX, positionY, positionZ);
st.homePosition = Eigen::Vector3f(positionX, 0.f, positionZ);
st.stopInPlace();
st.facingAngle = facingAngle;
st.walkSpeed = walkSpeed;
st.rotationSpeed = rotationSpeed;
@@ -396,6 +428,7 @@ namespace FRG {
st.initialHp = hp;
st.visibleRange = visibleRange;
st.canAttack = canAttack;
if (canAttack) st.attackTargetId = "player";
st.enabled = enabled;
st.interactionRadius = interactionRadius;
@@ -433,29 +466,51 @@ namespace FRG {
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)
{
std::vector<std::unique_ptr<Character>> npcs;
int index = 0;
for (const auto& npcData : loadNpcsFromJson(jsonPath, zipPath)) {
const auto definitions = loadNpcsFromJson(jsonPath, zipPath);
std::unordered_set<std::string> seenIds;
for (const auto& definition : definitions) {
const std::string& id = definition.id;
if (id == "player" || !seenIds.insert(id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate or reserved NPC id '" + id +
"' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (seenIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved NPC id '" + id +
"' is not present in " + jsonPath);
for (const auto& npcData : definitions) {
SDL_PumpEvents();
logger() << "Loading NPC: " << npcData.name << std::endl;
try {
auto npc = Character::createFromState(npcData.toCharacterState(), renderer, zipPath.c_str());
CharacterState definition = npcData.toCharacterState();
auto& state = states[npcData.id];
if (!state) state = std::make_shared<CharacterState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
auto npc = Character::createFromState(
*state, server, locationId, npcData.id, renderer, zipPath.c_str());
if (npc) {
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
npc->index = index;
npcs.push_back(std::move(npc));
index = index + 1;
}
} catch (const std::exception& e) {
std::cerr << "GameObjectLoader: Failed to load NPC '" << npcData.name << "': " << e.what() << std::endl;
}
}
logger() << "Total NPCs loaded: " << npcs.size() << std::endl;
return npcs;
}
+7 -1
View File
@@ -11,6 +11,7 @@
#include "../CharacterState.h"
namespace FRG {
class IGameServer;
class Character;
@@ -41,6 +42,7 @@ namespace FRG {
};
struct InteractiveObjectData {
std::string id;
GameObjectData base;
float interactionRadius = 2.0f;
float approachRadius = 2.0f;
@@ -105,13 +107,17 @@ namespace FRG {
static std::vector<InteractiveObject> loadAndCreateInteractiveObjects(
const std::string& jsonPath,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath = ""
);
static std::vector<std::unique_ptr<FRG::Character>> loadAndCreate_Npcs(
const std::string& jsonPath,
Renderer& renderer,
const std::string& zipPath = ""
std::unordered_map<std::string, std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath
);
static std::vector<NpcData> loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath = "");
+63 -65
View File
@@ -19,12 +19,11 @@ namespace FRG {
using std::max;
#endif
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState st,
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState& st,
Renderer& renderer,
const std::string& zipPath)
{
InteractiveObject obj;
obj.state = std::move(st);
InteractiveObject obj(st);
const LoadedGameObjectState& info = obj.state.objectInfo;
@@ -75,115 +74,115 @@ namespace FRG {
}
void InteractiveObject::moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::moveTo(
const Eigen::Vector3f& target, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = target;
task.targetRotY = state.rotationY;
task.targetScale = state.scale;
task.targetRotY = rotationY;
task.targetScale = 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;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::rotateTo(
float targetRotY, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = state.position;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = targetRotY;
task.targetScale = state.scale;
task.targetScale = 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;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::scaleTo(float targetScale, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::scaleTo(
float targetScale, float durationSec, std::string onCompleteName) {
if (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.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = 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;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::fadeTo(
float targetAlpha, float durationSec, std::string onCompleteName) {
if (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.startPos = position;
task.startRotY = rotationY;
task.startScale = alpha; // reuse startScale to hold start alpha
task.targetPos = position;
task.targetRotY = 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;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::update(int64_t deltaMs) {
if (!state.isAnimating || !state.animTask) return;
InteractiveObjectUpdateEvents InteractiveObjectState::update(std::int64_t deltaMs) {
InteractiveObjectUpdateEvents events;
if (!isAnimating || !animTask) return events;
AnimTask& task = *state.animTask;
AnimTask& task = *animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float t = min(task.elapsedMs / task.durationMs, 1.0f);
const float t = (std::min)(task.elapsedMs / task.durationMs, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
state.position = task.startPos + (task.targetPos - task.startPos) * t;
position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
state.rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
state.scale = task.startScale + (task.targetScale - task.startScale) * t;
scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
state.alpha = task.startScale + (task.targetScale - task.startScale) * t;
alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t >= 1.0f) {
state.position = task.targetPos;
state.rotationY = task.targetRotY;
position = task.targetPos;
rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
state.alpha = task.targetScale;
alpha = task.targetScale;
else
state.scale = task.targetScale;
scale = task.targetScale;
std::function<void()> cb = std::move(task.onComplete);
state.animTask.reset();
state.isAnimating = false;
if (cb) cb();
events.onCompleteName = std::move(task.onCompleteName);
animTask.reset();
isAnimating = false;
}
return events;
}
void InteractiveObject::drawDarklands(Renderer& renderer) const {
@@ -295,7 +294,6 @@ namespace FRG {
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);
}
}
+12 -12
View File
@@ -1,9 +1,6 @@
#pragma once
#include <string>
#include <memory>
#include <optional>
#include <functional>
#include <cstdint>
#include <Eigen/Core>
#include "render/Renderer.h"
#include "InteractiveObjectState.h"
@@ -21,25 +18,28 @@ namespace FRG {
};
struct InteractiveObject {
explicit InteractiveObject(InteractiveObjectState& boundState) : state(boundState) {}
std::string id;
LoadedGameObject loadedObject; // rendering assets
InteractiveObjectState state; // all mutable + creation state
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
// Loads textures and mesh from state.objectInfo, computes mesh centering,
// and sets state.position = jsonPosition + meshCenter.
static InteractiveObject createFromState(InteractiveObjectState st,
static InteractiveObject createFromState(InteractiveObjectState& state,
Renderer& renderer,
const std::string& zipPath = "");
// callbackName, if given, is the global Lua function name backing onComplete —
// stored in the AnimTask so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void scaleTo(float targetScale, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void update(int64_t deltaMs);
void draw(Renderer& renderer) const;
void drawDarklands(Renderer& renderer) const;
const std::string& entityId() const { return id; }
private:
friend class GameObjectLoader;
friend class Location;
friend class LocationEditor;
friend class ScriptEngine;
friend class InternalGameServer;
InteractiveObjectState& state; // non-owning binding to LocationGameState
};
} // namespace FRG
+12 -5
View File
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <optional>
#include <functional>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -21,10 +21,11 @@ struct AnimTask {
float targetScale = 1.f;
float durationMs = 1000.f;
float elapsedMs = 0.f;
std::function<void()> onComplete; // non-serializable; re-wired at runtime by Lua callbacks
// Global Lua function name backing onComplete, if it was sourced from a script
// (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback can be
// re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the animation completes. Empty means none.
std::string onCompleteName;
};
struct InteractiveObjectUpdateEvents {
std::string onCompleteName;
};
@@ -70,6 +71,12 @@ public:
bool isAnimating = false;
std::optional<AnimTask> animTask;
void moveTo(const Eigen::Vector3f& target, float durationSec, std::string onCompleteName = {});
void rotateTo(float targetRotY, float durationSec, std::string onCompleteName = {});
void scaleTo(float targetScale, float durationSec, std::string onCompleteName = {});
void fadeTo(float targetAlpha, float durationSec, std::string onCompleteName = {});
InteractiveObjectUpdateEvents update(std::int64_t deltaMs);
// --- Serialisation (runtime mutable fields only) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
+4 -7
View File
@@ -3,6 +3,7 @@
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include "../utils/Utils.h"
namespace FRG {
@@ -44,13 +45,9 @@ namespace FRG {
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);
}
if (!j.is_string())
throw std::runtime_error("Inventory item IDs must be strings");
itemIds.push_back(j.get<std::string>());
}
}
+6 -6
View File
@@ -101,12 +101,12 @@ namespace FRG {
}
std::vector<Eigen::Vector3f> route;
route.reserve(player.state.pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(player.state.position.x(), 0.0f, player.state.position.z()));
route.reserve(player.characterState.pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(player.characterState.position.x(), 0.0f, player.characterState.position.z()));
const size_t firstWaypoint = (std::min)(
player.state.currentWaypointIndex, player.state.pathWaypoints.size());
for (size_t i = firstWaypoint; i < player.state.pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = player.state.pathWaypoints[i];
player.characterState.currentWaypointIndex, player.characterState.pathWaypoints.size());
for (size_t i = firstWaypoint; i < player.characterState.pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = player.characterState.pathWaypoints[i];
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
}
@@ -127,7 +127,7 @@ namespace FRG {
constexpr float kPlayerClearanceMs = 20.0f;
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
const float playerSpeed = (std::max)(
player.state.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
player.characterState.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f;
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
const float waveSpeed = totalLength / (revealDurationMs / 1000.0f);