diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md new file mode 100644 index 0000000..238973a --- /dev/null +++ b/GAME_STATE_ARCHITECTURE.md @@ -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. diff --git a/proj-android/app/jni/src/CMakeLists.txt b/proj-android/app/jni/src/CMakeLists.txt index 9626416..18dc632 100644 --- a/proj-android/app/jni/src/CMakeLists.txt +++ b/proj-android/app/jni/src/CMakeLists.txt @@ -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 diff --git a/proj-linux/CMakeLists.txt b/proj-linux/CMakeLists.txt index d040f81..b55ea07 100644 --- a/proj-linux/CMakeLists.txt +++ b/proj-linux/CMakeLists.txt @@ -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 diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index e68703c..8c02ea1 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -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 diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 256230d..6b918dd 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -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 diff --git a/resources/config/interactive_objects_dorm_x.json b/resources/config/interactive_objects_dorm_x.json index bcebc97..ebf6741 100644 --- a/resources/config/interactive_objects_dorm_x.json +++ b/resources/config/interactive_objects_dorm_x.json @@ -1,6 +1,7 @@ { "objects": [ { + "id": "WashingMachine002", "name": "WashingMachine002", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -27,6 +28,7 @@ "activateFunction": "on_washing_machine1_click" }, { + "id": "Dryer002", "name": "Dryer002", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -53,6 +55,7 @@ "activateFunction": "on_dryer1_click" }, { + "id": "Toilet001", "name": "Toilet001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -79,6 +82,7 @@ "activateFunction": "on_toilet_click" }, { + "id": "Sink002", "name": "Sink002", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -105,6 +109,7 @@ "activateFunction": "on_sink1_click" }, { + "id": "Sink003", "name": "Sink003", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -131,6 +136,7 @@ "activateFunction": "on_sink1_click" }, { + "id": "Sink004", "name": "Sink004", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -157,6 +163,7 @@ "activateFunction": "on_sink1_click" }, { + "id": "Sink005", "name": "Sink005", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -183,6 +190,7 @@ "activateFunction": "on_sink1_click" }, { + "id": "Shower001", "name": "Shower001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -209,6 +217,7 @@ "activateFunction": "on_shower_click" }, { + "id": "Freezer001", "name": "Freezer001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -235,6 +244,7 @@ "activateFunction": "on_freezer_click" }, { + "id": "Boombox001", "name": "Boombox001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -261,6 +271,7 @@ "activateFunction": "on_boombox_click" }, { + "id": "WashingMachine001", "name": "WashingMachine001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -287,6 +298,7 @@ "activateFunction": "on_washing_machine1_click" }, { + "id": "Shelf001", "name": "Shelf001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -313,6 +325,7 @@ "activateFunction": "on_shelf_click" }, { + "id": "Stove001", "name": "Stove001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -339,6 +352,7 @@ "activateFunction": "on_stove1_click" }, { + "id": "Dryer001", "name": "Dryer001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -365,6 +379,7 @@ "activateFunction": "on_dryer1_click" }, { + "id": "Sink001", "name": "Sink001", "texturePath": "resources/transparent.png", "meshPath": "resources/w/Cube_001.txt.bin", @@ -391,6 +406,7 @@ "activateFunction": "on_sink1_click" }, { + "id": "Door_Room_1_1_0_Leaf_001", "name": "Door_Room_1_1_0_Leaf_001", "texturePath": "resources/w/dorm2/interior_door_tex003.png", "meshPath": "resources/w/dorm2/Door_Room_1_1_0_Leaf_001.txt.bin", @@ -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", diff --git a/resources/config/interactive_objects_uni_exterior_x.json b/resources/config/interactive_objects_uni_exterior_x.json index c5919d3..0cd755c 100644 --- a/resources/config/interactive_objects_uni_exterior_x.json +++ b/resources/config/interactive_objects_uni_exterior_x.json @@ -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", diff --git a/resources/config/interactive_objects_uni_interior_x.json b/resources/config/interactive_objects_uni_interior_x.json index 392f635..6c95e9b 100644 --- a/resources/config/interactive_objects_uni_interior_x.json +++ b/resources/config/interactive_objects_uni_interior_x.json @@ -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", diff --git a/resources/config/npcs_uni_interior.json b/resources/config/npcs_uni_interior.json index 32bbf1f..2e4bc54 100644 --- a/resources/config/npcs_uni_interior.json +++ b/resources/config/npcs_uni_interior.json @@ -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", diff --git a/resources/config/start_state.json b/resources/config/start_state.json index 54c4805..ffff7e0 100644 --- a/resources/config/start_state.json +++ b/resources/config/start_state.json @@ -1,4 +1,5 @@ { + "campaignId": "main", "chatHistory": [ [], [], @@ -14,7 +15,7 @@ true, true ], - "currentLocationName": "", + "currentLocationId": "", "globalFloats": { "player_hp": 200.0 }, @@ -27,13 +28,13 @@ "isNight": false, "locations": { "location_dorm": { - "dialogueState": { + "dialogue": { "active": true, "currentNodeId": "line_1", "dialogueId": "dialog_start001" }, - "interactiveObjectStates": [ - { + "interactiveObjects": { + "Bed001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -43,7 +44,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Boombox001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -53,167 +54,27 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Bath_-1_-1_1_Leaf_002": { "alpha": 1.0, "isActive": true, "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.0, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.100000023841858, + "positionX": -2.825000047683716, "positionY": 0.9695000052452087, - "positionZ": 10.0, + "positionZ": -14.300000190734863, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Bath_-1_1_1_Leaf_002": { "alpha": 1.0, "isActive": true, "isAnimating": false, - "positionX": -1.100000023841858, + "positionX": 2.825000047683716, "positionY": 0.9695000052452087, - "positionZ": 10.0, + "positionZ": -14.300000190734863, "rotationY": 0.0, "scale": 1.0 }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.100000023841858, - "positionY": 0.9695000052452087, - "positionZ": -10.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { + "Door_Room_-1_-1_0_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -223,17 +84,47 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Room_-1_-1_1_Leaf_001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": -1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": -15.699999809265137, + "rotationY": 0.0, + "scale": 1.0 + }, + "Door_Room_-1_1_0_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, "positionX": 1.100000023841858, "positionY": 0.9695000052452087, - "positionZ": 15.699999809265137, + "positionZ": -10.0, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Room_-1_1_1_Leaf_001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": -15.699999809265137, + "rotationY": 0.0, + "scale": 1.0 + }, + "Door_Room_1_-1_0_Leaf_001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": -1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": 10.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Door_Room_1_-1_1_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -243,52 +134,27 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Room_1_1_0_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, "positionX": 1.100000023841858, "positionY": 0.9695000052452087, - "positionZ": 19.950000762939453, + "positionZ": 10.0, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Room_1_1_1_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, - "positionX": -1.100000023841858, - "positionY": 0.9695000052452087, - "positionZ": 19.950000762939453, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "animTask": { - "durationMs": 10.0, - "elapsedMs": 0.0, - "startPosX": 1.100000023841858, - "startPosY": 0.9695000052452087, - "startPosZ": -19.950000762939453, - "startRotY": 0.0, - "startScale": 1.0, - "targetPosX": 1.100000023841858, - "targetPosY": 0.9695000052452087, - "targetPosZ": -19.950000762939453, - "targetRotY": 1.5707963705062866, - "targetScale": 1.0, - "type": 1 - }, - "isActive": true, - "isAnimating": true, "positionX": 1.100000023841858, "positionY": 0.9695000052452087, - "positionZ": -19.950000762939453, + "positionZ": 15.699999809265137, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Utility_-1_-1_2_Leaf_001": { "alpha": 1.0, "animTask": { "durationMs": 10.0, @@ -313,27 +179,52 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Utility_-1_1_2_Leaf_001": { "alpha": 1.0, + "animTask": { + "durationMs": 10.0, + "elapsedMs": 0.0, + "startPosX": 1.100000023841858, + "startPosY": 0.9695000052452087, + "startPosZ": -19.950000762939453, + "startRotY": 0.0, + "startScale": 1.0, + "targetPosX": 1.100000023841858, + "targetPosY": 0.9695000052452087, + "targetPosZ": -19.950000762939453, + "targetRotY": 1.5707963705062866, + "targetScale": 1.0, + "type": 1 + }, "isActive": true, - "isAnimating": false, - "positionX": 6.664400577545166, - "positionY": 0.8999999761581421, - "positionZ": -12.526200294494629, + "isAnimating": true, + "positionX": 1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": -19.950000762939453, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Utility_1_-1_2_Leaf_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, - "positionX": 5.842959880828857, - "positionY": 0.9284919500350952, - "positionZ": -12.466099739074707, + "positionX": -1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": 19.950000762939453, "rotationY": 0.0, "scale": 1.0 }, - { + "Door_Utility_1_1_2_Leaf_001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 1.100000023841858, + "positionY": 0.9695000052452087, + "positionZ": 19.950000762939453, + "rotationY": 0.0, + "scale": 1.0 + }, + "Dryer001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -343,7 +234,37 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Dryer002": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Freezer001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Journal001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 5.842959880828857, + "positionY": 0.9284919500350952, + "positionZ": -12.466099739074707, + "rotationY": 0.0, + "scale": 1.0 + }, + "Music001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -353,47 +274,27 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Phone001": { "alpha": 1.0, "isActive": true, "isAnimating": false, - "positionX": 2.825000047683716, - "positionY": 0.9695000052452087, - "positionZ": -14.300000190734863, + "positionX": 6.664400577545166, + "positionY": 0.8999999761581421, + "positionZ": -12.526200294494629, "rotationY": 0.0, "scale": 1.0 }, - { + "Roof": { "alpha": 1.0, - "isActive": true, + "isActive": false, "isAnimating": false, - "positionX": -2.825000047683716, - "positionY": 0.9695000052452087, - "positionZ": -14.300000190734863, + "positionX": 0.0, + "positionY": 3.2049999237060547, + "positionZ": 0.0, "rotationY": 0.0, "scale": 1.0 }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.100000023841858, - "positionY": 0.9695000052452087, - "positionZ": -15.699999809265137, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -1.100000023841858, - "positionY": 0.9695000052452087, - "positionZ": -15.699999809265137, - "rotationY": 0.0, - "scale": 1.0 - }, - { + "Room_Cover_Bath_W_N_2_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -403,7 +304,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_LivingRoom_W_N_2_001": { "alpha": 1.0, "isActive": false, "isAnimating": false, @@ -413,7 +314,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_LivingRoom_W_S_2_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -423,7 +324,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_Main_Hall_And_Corridors_002": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -433,7 +334,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_Others_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -443,7 +344,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_Utility_W_N_3_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -453,7 +354,7 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Room_Cover_Utility_W_S_3_001": { "alpha": 1.0, "isActive": true, "isAnimating": false, @@ -463,84 +364,127 @@ "rotationY": 0.0, "scale": 1.0 }, - { + "Shelf001": { "alpha": 1.0, - "isActive": false, + "isActive": true, "isAnimating": false, "positionX": 0.0, - "positionY": 3.2049999237060547, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Shower001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Sink001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Sink002": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Sink003": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Sink004": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Sink005": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Stove001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "Toilet001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "WashingMachine001": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "WashingMachine002": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.0, "positionZ": 0.0, "rotationY": 0.0, "scale": 1.0 } - ], - "locationState": { - "activeNavigationIndex": 0, - "cameraAzimuth": -2.3499999046325684, - "cameraInclination": 1.103600025177002, - "isDarklands": false, - "isDawn": false, - "isNight": false, - "targetInteractNpcIndex": -1, - "targetInteractiveObjectIndex": -1, - "targetTeleportZoneIndex": -1, - "taxi": { - "taxiArrivedCallbackCalled": false, - "taxiCar": { - "currentWaypoint": 0, - "mode": 1, - "positionX": -20.850000381469727, - "positionY": 0.0, - "positionZ": 64.0, - "rotation": 0.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypointReachRadius": 5.0, - "waypoints": [] - }, - "taxiCarInitialState": { - "currentWaypoint": 0, - "mode": 1, - "positionX": -20.850000381469727, - "positionY": 0.0, - "positionZ": 64.0, - "rotation": 0.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypointReachRadius": 5.0, - "waypoints": [] - }, - "taxiCarTriggerPositionX": -19.0, - "taxiCarTriggerPositionY": 0.0, - "taxiCarTriggerPositionZ": 0.0, - "taxiDefaultWaypoints": [ - { - "x": -20.850000381469727, - "y": 0.0, - "z": 30.0 - }, - { - "x": -19.350000381469727, - "y": 0.0, - "z": 24.0 - }, - { - "x": -19.350000381469727, - "y": 0.0, - "z": 14.0 - } - ] - } }, - "npcStates": [ - { - "attackTargetIndex": -1, + "npcs": { + "npc_alik": { + "animationFrame": 0.0, + "attackTargetId": "", "attack_cooldown": 0.0, "battle_state": 0, "currentState": 0, "currentWaypointIndex": 0, "enabled": false, - "faceTargetIndex": -1, + "faceTargetId": "", "facingAngle": 1.5707963705062866, "homeDriftCheckTimer": 5.0, "homePosition": [ @@ -575,48 +519,6 @@ -15.041899681091309 ] } - ], - "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": [ - [ - 6.8031005859375, - 0.0, - -14.641900062561035 - ] - ], - "position": [ - 6.7634501457214355, - 0.0, - -14.60219955444336 - ], - "requestedWalkTarget": [ - 6.7634501457214355, - 0.0, - -14.60219955444336 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 6.8031005859375, - 0.0, - -14.641900062561035 - ] }, "scriptGlobals": { "alik_door_opened": false, @@ -625,11 +527,36 @@ "phone_picked_up": false, "player_door_opened": false }, - "teleportZoneState": { + "state": { + "activeNavigationIndex": 0, + "cameraAzimuth": -2.3499999046325684, + "cameraInclination": 1.103600025177002, + "targetInteractNpcId": "", + "targetInteractiveObjectId": "", + "targetTeleportZoneId": "", + "taxi": { + "arrivedCallbackCalled": false, + "car": { + "currentWaypoint": 0, + "mode": 1, + "position": [ + -20.850000381469727, + 0.0, + 64.0 + ], + "rotation": 0.0, + "steeringAngle": 0.0, + "velocity": 0.0, + "waypoints": [], + "wheelAngle": 0.0 + } + } + }, + "teleportZones": { "tp_dorm_to_2floor": false, "tp_dorm_to_loc2": false }, - "triggerZoneState": { + "triggerZones": { "ladder_zone001": { "enabled": true, "playerInside": false @@ -643,868 +570,56 @@ "playerInside": false } } - }, - "uni_exterior": { - "dialogueState": { - "active": false - }, - "interactiveObjectStates": [ - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -27.003389358520508, - "positionY": 0.328900009393692, - "positionZ": 8.820399284362793, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -10.496715545654297, - "positionY": 0.901249885559082, - "positionZ": -13.977367401123047, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -10.5, - "positionY": 0.335999995470047, - "positionZ": -13.999500274658203, - "rotationY": 0.0, - "scale": 1.0 - } - ], - "locationState": { - "activeNavigationIndex": 0, - "cameraAzimuth": -2.3499999046325684, - "cameraInclination": 1.103600025177002, - "isDarklands": false, - "isDawn": false, - "isNight": false, - "targetInteractNpcIndex": -1, - "targetInteractiveObjectIndex": -1, - "targetTeleportZoneIndex": -1, - "taxi": { - "taxiArrivedCallbackCalled": false, - "taxiCar": { - "currentWaypoint": 0, - "mode": 1, - "positionX": -17.600000381469727, - "positionY": 0.05000000074505806, - "positionZ": -100.0, - "rotation": 180.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypointReachRadius": 5.0, - "waypoints": [] - }, - "taxiCarInitialState": { - "currentWaypoint": 0, - "mode": 1, - "positionX": -17.600000381469727, - "positionY": 0.05000000074505806, - "positionZ": -100.0, - "rotation": 180.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypointReachRadius": 5.0, - "waypoints": [] - }, - "taxiCarTriggerPositionX": -17.600000381469727, - "taxiCarTriggerPositionY": 0.05000000074505806, - "taxiCarTriggerPositionZ": -45.0, - "taxiDefaultWaypoints": [ - { - "x": -17.600000381469727, - "y": 0.05000000074505806, - "z": -90.0 - }, - { - "x": -17.600000381469727, - "y": 0.05000000074505806, - "z": -75.0 - }, - { - "x": -17.600000381469727, - "y": 0.05000000074505806, - "z": -55.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": [ - 5.0, - 0.0, - -18.399999618530273 - ], - "requestedWalkTarget": [ - 5.0, - 0.0, - -18.399999618530273 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 5.0, - 0.0, - -18.399999618530273 - ] - }, - "scriptGlobals": null, - "teleportZoneState": { - "tp_loc2_to_dorm": false, - "tp_loc2_to_loc1": true - }, - "triggerZoneState": { - "darklands_exit001": { - "enabled": true, - "playerInside": false - }, - "darklands_exit003": { - "enabled": true, - "playerInside": false - }, - "darklands_exit004": { - "enabled": true, - "playerInside": false - }, - "darklands_exit005": { - "enabled": true, - "playerInside": false - }, - "taxi_zone001": { - "enabled": true, - "playerInside": false - } - } - }, - "uni_interior": { - "dialogueState": { - "active": false - }, - "interactiveObjectStates": [ - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.0, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.0, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.0, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.0, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -5.365539073944092, - "positionY": 0.9888315200805664, - "positionZ": 2.4683666229248047, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 5.0175323486328125, - "positionY": 1.3534114360809326, - "positionZ": 0.6829134821891785, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -4.959765434265137, - "positionY": 0.935509979724884, - "positionZ": 2.3698995113372803, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.6809704899787903, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.8479499816894531, - "positionY": 0.0, - "positionZ": 2.905519962310791, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -5.064300060272217, - "positionY": 1.0603100061416626, - "positionZ": 0.4027400016784668, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": false, - "isAnimating": false, - "positionX": 3.821929931640625, - "positionY": 1.0437999963760376, - "positionZ": 1.0303499698638916, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -10.496715545654297, - "positionY": -4.648750305175781, - "positionZ": -13.977367401123047, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -10.5, - "positionY": -4.664000034332275, - "positionZ": -13.999500274658203, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.5, - "positionY": 0.9750000238418579, - "positionZ": 6.400000095367432, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "animTask": { - "durationMs": 10.0, - "elapsedMs": 0.0, - "startPosX": 1.5, - "startPosY": 0.9750000238418579, - "startPosZ": -1.5999999046325684, - "startRotY": 0.0, - "startScale": 1.0, - "targetPosX": 1.5, - "targetPosY": 0.9750000238418579, - "targetPosZ": -1.5999999046325684, - "targetRotY": -1.5707963705062866, - "targetScale": 1.0, - "type": 1 - }, - "isActive": true, - "isAnimating": true, - "positionX": 1.5, - "positionY": 0.9750000238418579, - "positionZ": -1.5999999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -1.5, - "positionY": 0.9750000238418579, - "positionZ": -1.5999999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -1.5, - "positionY": 0.9750000238418579, - "positionZ": 6.400000095367432, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.9750000238418579, - "positionZ": 8.000000953674316, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -1.5, - "positionY": 0.9750000238418579, - "positionZ": -9.599998474121094, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": false, - "isAnimating": false, - "positionX": 2.25, - "positionY": 2.4499998092651367, - "positionZ": -3.999999761581421, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 2.4499998092651367, - "positionZ": 12.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -3.75, - "positionY": 2.4499998092651367, - "positionZ": -12.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": false, - "isAnimating": false, - "positionX": 3.75, - "positionY": 2.4499998092651367, - "positionZ": -4.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -3.75, - "positionY": 2.4499998092651367, - "positionZ": -4.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 3.75, - "positionY": 2.4499998092651367, - "positionZ": 4.0, - "rotationY": 0.0, - "scale": 1.0 - }, - { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -3.75, - "positionY": 2.4499998092651367, - "positionZ": 4.0, - "rotationY": 0.0, - "scale": 1.0 - } - ], - "locationState": { - "activeNavigationIndex": 0, - "cameraAzimuth": -2.3499999046325684, - "cameraInclination": 1.103600025177002, - "isDarklands": false, - "isDawn": false, - "isNight": false, - "targetInteractNpcIndex": -1, - "targetInteractiveObjectIndex": -1, - "targetTeleportZoneIndex": -1, - "taxi": { - "taxiArrivedCallbackCalled": false, - "taxiCar": { - "currentWaypoint": 0, - "mode": 1, - "positionX": -100.0, - "positionY": -100.0, - "positionZ": -100.0, - "rotation": 0.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypointReachRadius": 3.0, - "waypoints": [] - }, - "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": [] - } - }, - "npcStates": [ - { - "attackTargetIndex": -1, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 0.0, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - -0.2859399914741516, - 0.0, - 13.964099884033203 - ], - "hp": 100.0, - "pathWaypoints": [ - [ - -0.31001949310302734, - 0.0, - 13.974998474121094 - ] - ], - "position": [ - -0.2859399914741516, - 0.0, - 13.964099884033203 - ], - "requestedWalkTarget": [ - -0.2859399914741516, - 0.0, - 13.964099884033203 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - -0.31001949310302734, - 0.0, - 13.974998474121094 - ] - }, - { - "attackTargetIndex": -1, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 3.1415927410125732, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - 0.7996190190315247, - 0.0, - -9.556790351867676 - ], - "hp": 100.0, - "pathWaypoints": [ - [ - 0.7899808883666992, - 0.0, - -9.52500057220459 - ] - ], - "position": [ - 0.7996190190315247, - 0.0, - -9.556790351867676 - ], - "requestedWalkTarget": [ - 0.7996190190315247, - 0.0, - -9.556790351867676 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 0.7899808883666992, - 0.0, - -9.52500057220459 - ] - }, - { - "attackTargetIndex": -1, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 0.0, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - 0.0, - 0.0, - 13.964099884033203 - ], - "hp": 35.0, - "pathWaypoints": [ - [ - -0.010019302368164063, - 0.0, - 13.974998474121094 - ] - ], - "position": [ - 0.0, - 0.0, - 13.964099884033203 - ], - "requestedWalkTarget": [ - 0.0, - 0.0, - 13.964099884033203 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 5.5, - "walkTarget": [ - -0.010019302368164063, - 0.0, - 13.974998474121094 - ] - }, - { - "attackTargetIndex": -2, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 0.0, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - 0.0, - 0.0, - -6.400000095367432 - ], - "hp": 35.0, - "pathWaypoints": [ - [ - -0.010019302368164063, - 0.0, - -6.425000190734863 - ] - ], - "position": [ - 0.0, - 0.0, - -6.400000095367432 - ], - "requestedWalkTarget": [ - 0.0, - 0.0, - -6.400000095367432 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.5, - "walkTarget": [ - -0.010019302368164063, - 0.0, - -6.425000190734863 - ] - }, - { - "attackTargetIndex": -2, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 0.0, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - -5.0, - 0.0, - 12.0 - ], - "hp": 35.0, - "pathWaypoints": [ - [ - -5.010019302368164, - 0.0, - 11.974998474121094 - ] - ], - "position": [ - -5.0, - 0.0, - 12.0 - ], - "requestedWalkTarget": [ - -5.0, - 0.0, - 12.0 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - -5.010019302368164, - 0.0, - 11.974998474121094 - ] - }, - { - "attackTargetIndex": -2, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 0.0, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - 5.0, - 0.0, - 12.0 - ], - "hp": 35.0, - "pathWaypoints": [ - [ - 4.889980316162109, - 0.0, - 11.875 - ] - ], - "position": [ - 5.0, - 0.0, - 12.0 - ], - "requestedWalkTarget": [ - 5.0, - 0.0, - 12.0 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 4.800000190734863, - "walkTarget": [ - 4.889980316162109, - 0.0, - 11.875 - ] - }, - { - "attackTargetIndex": -1, - "attack_cooldown": 0.0, - "battle_state": 0, - "currentState": 0, - "currentWaypointIndex": 0, - "enabled": false, - "faceTargetIndex": -1, - "facingAngle": 3.1415927410125732, - "homeDriftCheckTimer": 5.0, - "homePosition": [ - 3.9899799823760986, - 0.0, - 10.375 - ], - "hp": 100.0, - "pathWaypoints": [ - [ - 3.989980697631836, - 0.0, - 10.375 - ] - ], - "position": [ - 3.9899799823760986, - 0.0, - 10.375 - ], - "requestedWalkTarget": [ - 3.9899799823760986, - 0.0, - 10.375 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 3.989980697631836, - 0.0, - 10.375 - ] - } - ], - "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": [ - [ - 0.9899806976318359, - 0.0, - -9.625000953674316 - ] - ], - "position": [ - 0.9426940083503723, - 0.0, - -9.6310396194458 - ], - "requestedWalkTarget": [ - 0.9426940083503723, - 0.0, - -9.6310396194458 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 0.9899806976318359, - 0.0, - -9.625000953674316 - ] - }, - "scriptGlobals": { - "CONST_MSG_EMPTY": "(пусто)", - "CONST_MSG_WHERE": "Бекзат, ты где? Я жду...", - "first_time_darklands": true, - "ghost_gone": false, - "hall_door_opened": false, - "morning_can_open_door_index": 0, - "morning_did_open_door_index": 0, - "player_hold_book": false, - "teacher_arrived_to_library": false, - "teacher_door_opened": false, - "teacher_told_about_book": false, - "was_day_when_player_left": 0, - "was_state_when_player_left": 0 - }, - "teleportZoneState": { - "tp_loc1_to_loc2": true - }, - "triggerZoneState": { - "book_dialog_zone001": { - "enabled": true, - "playerInside": false - }, - "knife_dialog_zone001": { - "enabled": true, - "playerInside": false - }, - "lection_hall_zone001": { - "enabled": true, - "playerInside": false - }, - "teacher_dialog_zone001": { - "enabled": false, - "playerInside": false - } - } } }, "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.8031005859375, + 0.0, + -14.641900062561035 + ] + ], + "position": [ + 6.7634501457214355, + 0.0, + -14.60219955444336 + ], + "requestedWalkTarget": [ + 6.7634501457214355, + 0.0, + -14.60219955444336 + ], + "showWeapon": true, + "targetFacingAngle": 0.0, + "visibleRange": 6.0, + "walkTarget": [ + 6.8031005859375, + 0.0, + -14.641900062561035 + ] + } + }, "questJournal": { "quests": [ { @@ -1648,10 +763,12 @@ } ] }, - "savedAt": "2026-07-24 23:40", + "savedAt": "2026-10-28 23:16", + "schemaVersion": 1, "taxiIsCalled": false, "tutorialJournalScreenOpened": false, "tutorialNeedOpenTaxiScreen": false, "tutorialPhoneChatScreenOpened": false, - "tutorialStep": 0 + "tutorialStep": 0, + "tutorialTaxiHintDone": false } \ No newline at end of file diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json index 0879131..3219ec4 100644 --- a/resources/config/start_state_battle_zone.json +++ b/resources/config/start_state_battle_zone.json @@ -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 } \ No newline at end of file diff --git a/resources/start_battle_zone.lua b/resources/start_battle_zone.lua index 77b8294..65a0d6e 100644 --- a/resources/start_battle_zone.lua +++ b/resources/start_battle_zone.lua @@ -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 ) diff --git a/resources/start_dorm.lua b/resources/start_dorm.lua index ca53c81..27e530a 100644 --- a/resources/start_dorm.lua +++ b/resources/start_dorm.lua @@ -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--!") diff --git a/resources/start_uni_exterior.lua b/resources/start_uni_exterior.lua index 807a945..fa2e888 100644 --- a/resources/start_uni_exterior.lua +++ b/resources/start_uni_exterior.lua @@ -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( diff --git a/resources/start_uni_interior.lua b/resources/start_uni_interior.lua index 14d818f..fa26aaf 100644 --- a/resources/start_uni_interior.lua +++ b/resources/start_uni_interior.lua @@ -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!---!") diff --git a/resources/w/shelter/interactive_objects.json b/resources/w/shelter/interactive_objects.json index 61209b6..01a7aad 100644 --- a/resources/w/shelter/interactive_objects.json +++ b/resources/w/shelter/interactive_objects.json @@ -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", diff --git a/src/Character.cpp b/src/Character.cpp index fbf0e6a..5195dd5 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -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::createFromState(CharacterState st, Renderer& renderer, const char* zipFile) { - auto c = std::make_unique(); - c->state = std::move(st); +std::unique_ptr 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(st, server, locationId, entityId); const std::string zip = zipFile ? zipFile : ""; @@ -56,7 +62,7 @@ std::unique_ptr 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 { @@ -70,27 +76,27 @@ std::unique_ptr 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::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 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 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 } 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(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(static_cast(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(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(M_PI)) angleDiff -= 2.f * static_cast(M_PI); - while (angleDiff < -static_cast(M_PI)) angleDiff += 2.f * static_cast(M_PI); - float rotStep = state.rotationSpeed * static_cast(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(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(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(deltaMs) / 24.f; - - if (static_cast(anim.currentFrame) >= 20 && state.currentState == AnimationState::STAND_TO_ACTION) { - state.showWeapon = true; - } else if (static_cast(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(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(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((std::max)(0, anim.totalFrames - 1))); if (static_cast(anim.currentFrame) != anim.lastFrame) { anim.gpuSkinningShaderData.ComputeSkinningMatrices( anim.model.startBones, anim.model.animations[0].keyFrames, static_cast(anim.currentFrame)); - if (!state.useGpuSkinning) { + if (!characterState.useGpuSkinning) { anim.model.Interpolate(static_cast(anim.currentFrame)); } anim.lastFrame = static_cast(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(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(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(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(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 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 color = state.canAttack + std::array color = characterState.canAttack ? std::array{ 1.f, 0.f, 0.f, 1.f } : std::array{ 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; diff --git a/src/Character.h b/src/Character.h index 4a3472f..aa9e56e 100644 --- a/src/Character.h +++ b/src/Character.h @@ -11,30 +11,25 @@ #include #include #include +#include #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; - - using PathPlanner = std::function( - 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 createFromState(CharacterState st, Renderer& renderer, const char* zipFile); + static std::unique_ptr 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 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 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 onDeathAnimComplete; + std::function onHpChanged; std::map animations; VertexRenderStruct modelMutable; VertexRenderStruct healthBarMesh; std::unordered_map> 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); diff --git a/src/CharacterState.cpp b/src/CharacterState.cpp index a5b505a..c849ba0 100644 --- a/src/CharacterState.cpp +++ b/src/CharacterState.cpp @@ -1,7 +1,17 @@ #include "CharacterState.h" +#include "LocationState.h" +#include +#include 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(animationFrame) < 20 || + (currentState != AnimationState::ACTION_ATTACK && + currentState != AnimationState::ACTION_ATTACK_2)) { + return; + } + const auto index = static_cast(currentState); + animationFrame = index < animationStartFrames.size() + ? static_cast(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(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(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( + static_cast(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(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(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(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(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(currentState); + animationFrame = index < animationStartFrames.size() + ? static_cast(animationStartFrames[index]) : 0.f; + } + const int previousFrame = static_cast(animationFrame); + animationFrame += static_cast(deltaMs) / 24.f; + + if (previousFrame < 19 && static_cast(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(animationFrame) >= 20 && currentState == AnimationState::STAND_TO_ACTION) || + (static_cast(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(currentState); + const int frameCount = animationIndex < animationFrameCounts.size() + ? animationFrameCounts[animationIndex] : 1; + if (frameCount > 1 && static_cast(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(currentState); + animationFrame = nextIndex < animationStartFrames.size() + ? static_cast(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(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(in.value("currentState", static_cast(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()); } diff --git a/src/CharacterState.h b/src/CharacterState.h index ffb55d5..fe3f634 100644 --- a/src/CharacterState.h +++ b/src/CharacterState.h @@ -2,7 +2,9 @@ #include #include #include -#include +#include +#include +#include #include #include #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 animationStartFrames = {{0, 0, 0, 0, 0, 0, 0, 0, 0}}; + std::array 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 onArrivedCallback; - std::function onDeathAnimComplete; - std::function 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>; + +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 diff --git a/src/Game.cpp b/src/Game.cpp index 012d301..55a9430 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -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()) + , internalGameServer() + , gameState(internalGameServer.hostState()) + , menuManager(renderer, internalGameServer, *audioPlayer) + , newTickCount(0) , lastTickCount(0) - , audioPlayer(std::make_unique()) - , 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(); + 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 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 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 Game::createLocation(const LocationConfig& config) { + LocationGameState& authoritative = gameState.locations[config.id]; + if (!authoritative.location) authoritative.location = std::make_shared(); + + 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(renderer, gameState.inventory); + auto loc = std::make_shared( + 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(); + info.locationName = root.at("currentLocationId").get(); 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); }); diff --git a/src/Game.h b/src/Game.h index 2fca701..bf43f9b 100644 --- a/src/Game.h +++ b/src/Game.h @@ -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 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 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); diff --git a/src/GameState.cpp b/src/GameState.cpp index 2f88eaf..e2de921 100644 --- a/src/GameState.cpp +++ b/src/GameState.cpp @@ -2,23 +2,10 @@ #include #include #include +#include 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(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(); + currentLocationName = in.at("currentLocationId").get(); 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()); - } else { - tutorialStep = static_cast(in.value("tutorialStep", static_cast(tutorialStep))); - } + tutorialStep = static_cast( + in.at("tutorialStep").get()); 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(); + 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; diff --git a/src/GameState.h b/src/GameState.h index b6a8c98..d874866 100644 --- a/src/GameState.h +++ b/src/GameState.h @@ -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 @@ -32,17 +32,17 @@ struct StoredChatMessage { bool incoming; }; -using LocationMap = std::unordered_map>; - 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 storedLocationStates; + // Pure authoritative location data. Live Location/render/script instances are + // private runtime storage in InternalGameServer and never enter GameState. + std::unordered_map 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]; diff --git a/src/Location.cpp b/src/Location.cpp index 617c8d9..b5fad04 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -6,13 +6,17 @@ #include "TextModel.h" #include #include +#include #include #include #include #include #include +#include +#include #include "GameConstants.h" #include "Character.h" +#include "game_server/IGameServer.h" #include "external/nlohmann/json.hpp" #include @@ -49,63 +53,110 @@ namespace FRG return (p2 - closest).norm(); } - Location::Location(Renderer& iRenderer, Inventory& iInventory) + Location::Location(Renderer& iRenderer, Inventory& iInventory, LocationState& boundState, + IGameServer& server, std::string locationId) : renderer(iRenderer) , inventory(iInventory) , editor(*this) + , state(boundState) + , gameServer_(server) + , locationId_(std::move(locationId)) { } Location::~Location() { // The player is shared with the next location and outlives this one: drop - // everything in him that points into this location: its path planner, the HP - // callback Game wires with this location's pointer, and a pending walk-to callback. + // everything in him that points into this location. if (player) { - player->setPathPlanner(nullptr); - player->state.onHpChanged = nullptr; - player->state.onArrivedCallback = nullptr; + player->onHpChanged = nullptr; } } - // ---- Pointer-to-index helpers ---- - - InteractiveObject* Location::getTargetInteractiveObject() + void Location::setCharacterTarget( + Character& character, const Eigen::Vector3f& target, std::string callbackName) { - const int idx = state.targetInteractiveObjectIndex; - if (idx < 0 || idx >= static_cast(interactiveObjects.size())) return nullptr; - return &interactiveObjects[idx]; + gameServer_.submit(SetCharacterDestinationCommand{ + locationId_, character.entityId(), target, std::move(callbackName)}); } + void Location::updateCharacterState( + Character& character, + const std::function& mutation) + { + CharacterState stateCopy = character.characterState; + mutation(stateCopy); + gameServer_.submit(CommitCharacterStateCommand{ + locationId_, character.entityId(), std::move(stateCopy)}); + } + + void Location::clearCharacterAttackTarget(Character& character) + { + gameServer_.submit(ClearCharacterAttackTargetCommand{ + locationId_, character.entityId()}); + } + + void Location::stopCharacter(Character& character) + { + gameServer_.submit(StopCharacterCommand{locationId_, character.entityId()}); + } + + void Location::faceCharacter(Character& character, std::string targetId) + { + gameServer_.submit(FaceCharacterCommand{ + locationId_, character.entityId(), std::move(targetId)}); + } + + void Location::replanCharacter(Character& character) + { + gameServer_.submit(ReplanCharacterCommand{locationId_, character.entityId()}); + } + + void Location::resetCharacterAfterDeath(Character& character) + { + gameServer_.submit( + ResetCharacterAfterDeathCommand{locationId_, character.entityId()}); + } + + void Location::tryCharacterAttackCombo(Character& character) + { + gameServer_.submit( + TryCharacterAttackComboCommand{locationId_, character.entityId()}); + } + + void Location::setCharacterHp(Character& character, float hp) + { + gameServer_.submit( + SetCharacterHealthCommand{locationId_, character.entityId(), hp}); + if (character.onHpChanged) + character.onHpChanged( + character.characterState.hp, + character.characterState.initialHp, + std::string()); + } + + // ---- Pending-target lookup ---- + TeleportZone* Location::getTargetTeleportZone() { - const int idx = state.targetTeleportZoneIndex; - if (idx < 0 || idx >= static_cast(teleportZones.size())) return nullptr; - return &teleportZones[idx]; - } - - int Location::findInteractiveObjectIndex(const InteractiveObject* obj) const - { - for (int i = 0; i < static_cast(interactiveObjects.size()); ++i) { - if (&interactiveObjects[i] == obj) return i; - } - return -1; - } - - int Location::findTeleportZoneIndex(const TeleportZone* tz) const - { - for (int i = 0; i < static_cast(teleportZones.size()); ++i) { - if (&teleportZones[i] == tz) return i; - } - return -1; + if (state.targetTeleportZoneId.empty()) return nullptr; + for (auto& zone : teleportZones) + if (zone.id == state.targetTeleportZoneId) return &zone; + return nullptr; } // ---- Setup ---- void Location::setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer) { + if (!params.gameState) throw std::runtime_error("Location requires authoritative state binding"); pathVisualization.initialize(); + editor.buildNavMeshes(); + + //setupNavigation(params.navigationJsonPaths); + + // Load static game objects { StaticObjectSet staticObjects = GameObjectLoader::loadAndCreateGameObjects(params.gameObjectsJsonPath, renderer, CONST_ZIP_FILE); @@ -115,31 +166,33 @@ namespace FRG SDL_PumpEvents(); // Load interactive objects - interactiveObjects = GameObjectLoader::loadAndCreateInteractiveObjects(params.interactiveObjectsJsonPath, renderer, CONST_ZIP_FILE); + interactiveObjects = GameObjectLoader::loadAndCreateInteractiveObjects( + params.interactiveObjectsJsonPath, renderer, params.gameState->interactiveObjects, + CONST_ZIP_FILE); SDL_PumpEvents(); auto sparkTexture = renderer.textureManager.LoadFromPng("resources/w/spark.png", CONST_ZIP_FILE); - player = params.player ? params.player : createPlayer(renderer, audioPlayer); - // A shared player arrives from another location; until setupNavigation() below - // gives him this location's planner, he must not plan with anything else. - player->setPathPlanner(nullptr); - player->state.position = params.playerPosition; - player->setTarget(params.playerPosition); + if (!params.player) throw std::runtime_error("Location requires game-level player binding"); + player = params.player; + player->bindToLocation(locationId_); + updateCharacterState(*player, [¶ms](CharacterState& workingState) { + workingState.position = params.playerPosition; + }); + setCharacterTarget(*player, params.playerPosition); logger() << "Load resurces step 9" << std::endl; SDL_PumpEvents(); // Load NPCs from JSON. Aggressive (canAttack) NPCs need a player target // and hit sparks; peaceful NPCs don't take damage from the player so // they don't need either. - npcs = GameObjectLoader::loadAndCreate_Npcs(params.npcsJsonPath, renderer, CONST_ZIP_FILE); + npcs = GameObjectLoader::loadAndCreate_Npcs( + params.npcsJsonPath, renderer, params.gameState->npcs, gameServer_, locationId_, CONST_ZIP_FILE); for (auto& npc : npcs) { if (!npc) continue; - npc->state.homePosition = Eigen::Vector3f(npc->state.position.x(), 0.f, npc->state.position.z()); - if (npc->state.canAttack) { + if (npc->characterState.canAttack) { npc->setupHitSparks(sparkTexture); npc->audioPlayer = audioPlayer; - npc->state.attackTargetIndex = CharacterState::kPlayerIndex; } } @@ -149,7 +202,6 @@ namespace FRG loadTriggerZones(params.triggerZonesJsonPath, CONST_ZIP_FILE); loadPointLights(params.lightsJsonPath, CONST_ZIP_FILE); - setupNavigation(params.navigationJsonPaths); SDL_PumpEvents(); dialogueSystem.init(renderer, CONST_ZIP_FILE); @@ -207,7 +259,8 @@ namespace FRG } - std::shared_ptr Location::createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer) + std::shared_ptr Location::createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer, + CharacterState& state, IGameServer& server) { //auto playerTexture = std::make_shared(CreateTextureDataFromPng("resources/w/gg/IMG_20260413_182354_992.png", CONST_ZIP_FILE)); //auto playerTexture = renderer.textureManager.LoadFromPng("resources/w/gg/UniV_Grid_2K_Base_color.png", CONST_ZIP_FILE); @@ -215,7 +268,17 @@ namespace FRG auto sparkTexture = renderer.textureManager.LoadFromPng("resources/w/spark.png", CONST_ZIP_FILE); - auto player = std::make_shared(); + // Configure server-owned setup state before binding the const Character view. + state.walkSpeed = 3.0f; + state.rotationSpeed = 8.0f; + state.modelScale = 1.f; + state.modelCorrectionRotation = Eigen::Quaternionf( + Eigen::AngleAxisf(M_PI, Eigen::Vector3f::UnitY()) * + Eigen::AngleAxisf(-M_PI * 0.5, Eigen::Vector3f::UnitX())); + state.canAttack = true; + state.isPlayer = true; + + auto player = std::make_shared(state, server, std::string(), "player"); player->loadBinaryAnimation(AnimationState::STAND, "resources/w/gg/new2/gg_stand_idle001_small.anim", CONST_ZIP_FILE); player->loadBinaryAnimation(AnimationState::WALK, "resources/w/gg/new2/gg_walk001_small.anim", CONST_ZIP_FILE); @@ -239,19 +302,6 @@ namespace FRG player->weaponInitialPosition = Eigen::Vector3f(0, 0.09, 0.016); player->setTexture(playerTexture); - player->state.walkSpeed = 3.0f; - player->state.rotationSpeed = 8.0f; - - player->state.modelScale = 1.f; - player->state.modelCorrectionRotation = Eigen::Quaternionf( - Eigen::AngleAxisf(M_PI, Eigen::Vector3f::UnitY()) - * - Eigen::AngleAxisf(-M_PI*0.5, Eigen::Vector3f::UnitX()) - - ); - - player->state.canAttack = true; - player->state.isPlayer = true; player->setupHitSparks(sparkTexture); player->audioPlayer = audioPlayer; @@ -293,9 +343,13 @@ namespace FRG auto activeTex = renderer.textureManager.LoadFromPng("resources/w/star.png", zipFile ? zipFile : ""); auto inactiveTex = renderer.textureManager.LoadFromPng("resources/w/star_red.png", zipFile ? zipFile : ""); + std::unordered_set ids; for (const auto& item : j["teleports"]) { TeleportZone tz; - tz.id = item.value("id", ""); + tz.id = item.at("id").get(); + if (tz.id.empty() || !ids.insert(tz.id).second) + throw std::runtime_error( + "Missing or duplicate teleport id in " + jsonPath); tz.active = item.value("active", false); tz.position = Eigen::Vector3f( item.value("positionX", 0.0f), @@ -352,9 +406,13 @@ namespace FRG if (!j.contains("trigger_zones") || !j["trigger_zones"].is_array()) return; + std::unordered_set ids; for (const auto& item : j["trigger_zones"]) { TriggerZone tz; - tz.id = item.value("id", ""); + tz.id = item.at("id").get(); + if (tz.id.empty() || !ids.insert(tz.id).second) + throw std::runtime_error( + "Missing or duplicate trigger-zone id in " + jsonPath); tz.enabled = item.value("enabled", true); tz.radius = item.value("radius", 1.5f); tz.hysteresis = item.value("hysteresis", 0.3f); @@ -448,82 +506,38 @@ namespace FRG } } - void Location::setupNavigation(const std::vector& paths) + /* void Location::setupNavigation(const std::vector& paths) { navigationMapPaths = paths; - navigationMaps.clear(); - navigationMaps.resize(paths.size()); + state.navigationMaps.clear(); + state.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); + state.navigationMaps[i].build(paths[i], CONST_ZIP_FILE); } state.activeNavigationIndex = 0; - navigation = navigationMaps.empty() ? nullptr : &navigationMaps[0]; - if (editorMode == EditorMode::Navigation && navigation) { + if (editorMode == EditorMode::Navigation) { editor.buildNavMeshes(); } - - static constexpr float kDynamicObstacleInfluenceDist = 6.0f; - - auto makePlanner = [this](const Character* self) { - return [this, self](const Eigen::Vector3f& start, const Eigen::Vector3f& end) { - if (!navigation) return std::vector{}; - std::vector dynamicObstacles; - dynamicObstacles.reserve(npcs.size() + 1); - - const auto addCharacter = [&](const Character* other) { - if (!other || other == self) return; - if (other->getHp() <= 0.f || !other->state.enabled) return; - if (other->isMoving()) return; - - if (distancePointToSegmentXZ(other->state.position, start, end) > kDynamicObstacleInfluenceDist) { - return; - } - - PathFinder::DynamicObstacle obs; - obs.position = Eigen::Vector3f(other->state.position.x(), navigation->getFloorY(), other->state.position.z()); - obs.radius = (std::max)(0.0f, other->state.collisionRadius * 0.6f); - dynamicObstacles.push_back(obs); - }; - - addCharacter(player.get()); - for (const auto& npc : npcs) { - addCharacter(npc.get()); - } - - return navigation->findPathToNearest(start, end, dynamicObstacles); - }; - }; - - if (player) { - player->setPathPlanner(makePlanner(player.get())); - } - - for (auto& npc : npcs) { - if (npc) { - npc->setPathPlanner(makePlanner(npc.get())); - } - } - } + }*/ bool Location::switchNavigation(int index) { - if (index < 0 || index >= static_cast(navigationMaps.size())) { + if (index < 0 || index >= static_cast(state.navigationMaps.size())) { std::cerr << "[NAV] switchNavigation: index " << index << " out of range\n"; return false; } state.activeNavigationIndex = index; - navigation = &navigationMaps[index]; // Force all characters to replan their paths against the new nav map. - if (player) player->forceReplan(); + if (player) replanCharacter(*player); for (auto& npc : npcs) { - if (npc) npc->forceReplan(); + if (npc) replanCharacter(*npc); } if (editorMode == EditorMode::Navigation) { @@ -633,12 +647,12 @@ namespace FRG for (auto& npc : npcs) { if (npc->getHp() <= 0.f) { - //logger() << "[RAYCAST_NPC] " << npc->state.npcId << " is dead, skipping" << std::endl; + //logger() << "[RAYCAST_NPC] " << npc->entityId() << " is dead, skipping" << std::endl; continue; } - if (!npc->state.enabled) continue; - const float dx = rayOrigin.x() - npc->state.position.x(); - const float dz = rayOrigin.z() - npc->state.position.z(); + if (!npc->characterState.enabled) continue; + const float dx = rayOrigin.x() - npc->characterState.position.x(); + const float dz = rayOrigin.z() - npc->characterState.position.z(); const float a = rayDir.x() * rayDir.x() + rayDir.z() * rayDir.z(); if (a < 1e-6f) continue; // purely-vertical ray; not a real click case @@ -655,7 +669,7 @@ namespace FRG float exitT = tFar; // Clip against the cylinder's vertical extent. - const float yMin = npc->state.position.y(); + const float yMin = npc->characterState.position.y(); const float yMax = yMin + NPC_CLICK_HEIGHT; if (std::abs(rayDir.y()) > 1e-6f) { const float tYa = (yMin - rayOrigin.y()) / rayDir.y(); @@ -669,7 +683,7 @@ namespace FRG if (entryT > exitT) continue; - //logger() << "[RAYCAST_NPC] " << npc->npcId << " hit at t=" << entryT << std::endl; + //logger() << "[RAYCAST_NPC] " << npc->entityId() << " hit at t=" << entryT << std::endl; if (entryT < closestDist) { closestDist = entryT; @@ -678,7 +692,7 @@ namespace FRG } if (closestNpc) { - //logger() << "[RAYCAST_NPC] HIT: " << closestNpc->npcId << std::endl; + //logger() << "[RAYCAST_NPC] HIT: " << closestNpc->entityId() << std::endl; } else { //logger() << "[RAYCAST_NPC] No NPC hit" << std::endl; @@ -736,7 +750,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -806,7 +820,7 @@ namespace FRG { if (!shadowMap) return; - const Eigen::Vector3f& sceneCenter = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& sceneCenter = player ? player->characterState.position : Eigen::Vector3f::Zero(); shadowMap->updateLightSpaceMatrix(sceneCenter); shadowMap->bind(); @@ -918,7 +932,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1043,7 +1057,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1191,7 +1205,7 @@ namespace FRG if (player && !pointLights.empty()) { float minDist = FLT_MAX; for (int i = 0; i < static_cast(pointLights.size()); ++i) { - const float d = (pointLights[i].position - player->state.position).norm(); + const float d = (pointLights[i].position - player->characterState.position).norm(); if (d < minDist) { minDist = d; nearestLightIdx = i; } } } @@ -1205,12 +1219,12 @@ namespace FRG if (!active && player) { if (pl.autoLightLimitX > 0.0f && pl.autoLightLimitY > 0.0f) { // Rectangular activation zone — independent of nearest-light heuristic - const float dx = std::abs(player->state.position.x() - pl.position.x()); - const float dz = std::abs(player->state.position.z() - pl.position.z()); + const float dx = std::abs(player->characterState.position.x() - pl.position.x()); + const float dz = std::abs(player->characterState.position.z() - pl.position.z()); active = (dx <= pl.autoLightLimitX && dz <= pl.autoLightLimitY); } else { - // Legacy: only the nearest autoLight activates, with optional distance cap - const float dist = (pl.position - player->state.position).norm(); + // only the nearest autoLight activates, with optional distance cap + const float dist = (pl.position - player->characterState.position).norm(); active = (i == nearestLightIdx) && (pl.autoLightDistance <= 0.0f || dist <= pl.autoLightDistance); } @@ -1226,7 +1240,7 @@ namespace FRG if (player && !activeLightIndices.empty()) { float minDist = FLT_MAX; for (int idx : activeLightIndices) { - const float d = (pointLights[idx].position - player->state.position).norm(); + const float d = (pointLights[idx].position - player->characterState.position).norm(); if (d < minDist) { minDist = d; shadowLight = &pointLights[idx]; } } } @@ -1274,7 +1288,7 @@ namespace FRG renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(state.cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); - const Eigen::Vector3f& camTarget = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& camTarget = player ? player->characterState.position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); renderer.TranslateMatrix({ 0, -1.f, 0 }); @@ -1414,21 +1428,13 @@ namespace FRG bool Location::setNavigationAreaAvailable(const std::string& areaName, bool available) { - if (!navigation) return false; - return navigation->setAreaAvailable(areaName, available); - } - - int Location::findNpcIndex(const Character* npc) const - { - for (int i = 0; i < static_cast(npcs.size()); ++i) { - if (npcs[i].get() == npc) return i; - } - return -1; + return state.navigationMaps[state.activeNavigationIndex].setAreaAvailable(areaName, available); } + /* void Location::fireBumpCallbacks(Character* mover, Character* standing) { - if (mover->state.isPlayer) { + if (mover->characterState.isPlayer) { // Player walked into a standing NPC const int idx = findNpcIndex(standing); if (idx < 0) return; @@ -1437,7 +1443,7 @@ namespace FRG cooldown = NPC_BUMP_CALLBACK_COOLDOWN; scriptEngine.callNpcBumpedByPlayerCallback(idx); } - } else if (standing->state.isPlayer) { + } else if (standing->characterState.isPlayer) { // Moving NPC walked into the standing player const int idx = findNpcIndex(mover); if (idx < 0) return; @@ -1447,16 +1453,16 @@ namespace FRG scriptEngine.callNpcBumpsPlayerCallback(idx); } } - } + }*/ void Location::nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom) { - if (!standing || standing->state.isPlayer) return; - if (!navigation || !navigation->isReady()) return; + if (!standing || standing->characterState.isPlayer) return; + if (state.navigationMaps[state.activeNavigationIndex].isReady()) return; static constexpr float kNudgeDist = 1.2f; - Eigen::Vector3f dir = standing->state.position - awayFrom; + Eigen::Vector3f dir = standing->characterState.position - awayFrom; dir.y() = 0.f; if (dir.norm() < 1e-3f) dir = Eigen::Vector3f(1.f, 0.f, 0.f); else dir.normalize(); @@ -1464,249 +1470,91 @@ namespace FRG const float angles[] = { 0.f, static_cast(M_PI * 0.5), static_cast(-M_PI * 0.5), static_cast(M_PI) }; for (float angle : angles) { Eigen::Vector3f rotated = Eigen::AngleAxisf(angle, Eigen::Vector3f::UnitY()) * dir; - Eigen::Vector3f candidate = standing->state.position + rotated * kNudgeDist; + Eigen::Vector3f candidate = standing->characterState.position + rotated * kNudgeDist; candidate.y() = 0.f; - if (navigation->isWalkable(candidate)) { - standing->setTarget(candidate); + if (state.navigationMaps[state.activeNavigationIndex].isWalkable(candidate)) { + setCharacterTarget(*standing, candidate); return; } } } - void Location::resolveCharacterCollisions() - { - std::vector characters; - characters.reserve(npcs.size() + 1); - - if (player) { - characters.push_back(player.get()); - } - for (auto& npc : npcs) { - if (npc) { - characters.push_back(npc.get()); - } - } - - if (characters.size() < 2) { - return; - } - - static constexpr int kIterations = 3; - static constexpr float kMinSeparationEps = 1e-4f; - - for (int iter = 0; iter < kIterations; ++iter) { - for (size_t i = 0; i < characters.size(); ++i) { - for (size_t j = i + 1; j < characters.size(); ++j) { - Character* a = characters[i]; - Character* b = characters[j]; - if (!a || !b) continue; - if (a->getHp() <= 0.f || b->getHp() <= 0.f || !a->state.enabled || !b->state.enabled) continue; - - const float minDist = a->state.collisionRadius + b->state.collisionRadius; - if (minDist <= 0.f) continue; - - const Eigen::Vector2f pa(a->state.position.x(), a->state.position.z()); - const Eigen::Vector2f pb(b->state.position.x(), b->state.position.z()); - const Eigen::Vector2f delta = pb - pa; - const float dist = delta.norm(); - - if (dist >= minDist) { - continue; - } - - Eigen::Vector2f normal(1.f, 0.f); - if (dist > kMinSeparationEps) { - normal = delta / dist; - } - - const float penetration = (minDist - dist); - const float push = penetration * 0.5f; - - Eigen::Vector3f newA = a->state.position; - Eigen::Vector3f newB = b->state.position; - newA.x() -= normal.x() * push; - newA.z() -= normal.y() * push; - newA.y() = 0.f; - newB.x() += normal.x() * push; - newB.z() += normal.y() * push; - newB.y() = 0.f; - - const bool aWasMoving = a->isMoving(); - const bool bWasMoving = b->isMoving(); - - if (navigation && navigation->isReady()) { - const bool aOk = navigation->isWalkable(newA); - const bool bOk = navigation->isWalkable(newB); - - if (aOk && bOk) { - a->state.position = newA; - b->state.position = newB; - } - else if (aOk && !bOk) { - a->state.position = newA; - } - else if (!aOk && bOk) { - b->state.position = newB; - } - } - else { - a->state.position = newA; - b->state.position = newB; - } - - if (a->state.isPlayer && !aWasMoving) a->stopInPlace(); - if (b->state.isPlayer && !bWasMoving) b->stopInPlace(); - - if (aWasMoving && !bWasMoving) { - nudgeCharacterAside(b, a->state.position); - fireBumpCallbacks(a, b); - } else if (bWasMoving && !aWasMoving) { - nudgeCharacterAside(a, b->state.position); - fireBumpCallbacks(b, a); - } - } - } - } - } - - void Location::updateDynamicReplans(int64_t deltaMs) - { - static constexpr float kMovedEps = 0.05f; - static constexpr float kReplanTriggerDist = 1.8f; - static constexpr int64_t kReplanCooldownMs = 500; - static constexpr float kStuckMovementThreshold = 0.02f; - static constexpr int kStuckFrameThreshold = 4; - static constexpr float kStuckPauseMin = 0.5f; - static constexpr float kStuckPauseMax = 1.0f; - - const float deltaS = static_cast(deltaMs) / 1000.f; - - // Tick down pause timers; replan NPCs when their pause expires. - for (auto it = stuckPauseRemainingS.begin(); it != stuckPauseRemainingS.end();) { - it->second -= deltaS; - if (it->second <= 0.f) { - if (!it->first->state.isPlayer) { - it->first->forceReplan(); - } - it = stuckPauseRemainingS.erase(it); - } else { - ++it; - } - } - - for (auto it = replanCooldownRemainingMs.begin(); it != replanCooldownRemainingMs.end();) { - it->second -= deltaMs; - if (it->second <= 0) { - it = replanCooldownRemainingMs.erase(it); - } - else { - ++it; - } - } - - std::vector characters; - characters.reserve(npcs.size() + 1); - if (player) characters.push_back(player.get()); - for (auto& npc : npcs) if (npc) characters.push_back(npc.get()); - - std::vector movers; - movers.reserve(characters.size()); - for (Character* c : characters) { - if (!c) continue; - auto it = lastCharacterPositions.find(c); - if (it == lastCharacterPositions.end()) { - lastCharacterPositions[c] = c->state.position; - continue; - } - - const Eigen::Vector3f prev = it->second; - const float moved = (c->state.position - prev).norm(); - it->second = c->state.position; - - if (moved > kMovedEps) { - movers.push_back(c); - } - - if (c->isMoving()) { - if (moved < kStuckMovementThreshold) { - if (++stuckFrameCounts[c] >= kStuckFrameThreshold) { - c->stopInPlace(); - stuckFrameCounts[c] = 0; - if (stuckPauseRemainingS.find(c) == stuckPauseRemainingS.end()) { - if (!c->state.isPlayer) { - static std::mt19937 rng(std::random_device{}()); - std::uniform_real_distribution pauseDist(kStuckPauseMin, kStuckPauseMax); - stuckPauseRemainingS[c] = pauseDist(rng); - } - // Player just stops; replan is not scheduled. - } - } - } else { - stuckFrameCounts[c] = 0; - } - } else { - stuckFrameCounts[c] = 0; - } - } - - if (movers.empty()) { - return; - } - - for (Character* mover : movers) { - if (!mover) continue; - - for (Character* walker : characters) { - if (!walker || walker == mover) continue; - if (walker->getHp() <= 0.f || !walker->state.enabled) continue; - if (!walker->isMoving()) continue; - - if (replanCooldownRemainingMs.find(walker) != replanCooldownRemainingMs.end()) { - continue; - } - - const Eigen::Vector3f nextTarget = walker->getCurrentNavigationTarget(); - const float distToSegment = distancePointToSegmentXZ(mover->state.position, walker->state.position, nextTarget); - if (distToSegment > kReplanTriggerDist) { - continue; - } - - walker->forceReplan(); - replanCooldownRemainingMs[walker] = kReplanCooldownMs; - } - } - } - - void Location::update(int64_t delta) + void Location::update(int64_t delta, const LocationSimulationEvents& events) { pathVisualization.update(delta); + if (events.taxiArrived) + scriptEngine.callCallTaxiCallback(); - for (auto& intObj : interactiveObjects) { - intObj.update(delta); + for (const InteractiveObjectSimulationEvent& event : events.interactiveObjects) { + try { + if (!event.animationCompleteFunctionName.empty()) + scriptEngine.callFunction(event.animationCompleteFunctionName); + if (event.activated) { + if (!event.activateFunctionName.empty()) + scriptEngine.callActivateFunction(event.activateFunctionName); + else + scriptEngine.callItemPickupCallback(event.objectId); + } + } catch (const std::exception& e) { + std::cerr << "[interactive object] callback error: " + << e.what() << "\n"; + } + } + if (!events.npcInteractionId.empty()) { + try { + scriptEngine.callNpcInteractCallback(events.npcInteractionId); + } catch (const std::exception& e) { + std::cerr << "[NPC] callback error: " << e.what() << std::endl; + } } - auto resolver = [&](int idx) -> Character* { - if (idx == CharacterState::kNoTarget) return nullptr; - if (idx == CharacterState::kPlayerIndex) return player.get(); - if (idx >= 0 && idx < static_cast(npcs.size())) return npcs[idx].get(); + auto findRuntimeCharacter = [&](const std::string& id) -> Character* { + if (id == "player") return player.get(); + for (auto& npc : npcs) + if (npc && npc->entityId() == id) return npc.get(); + return nullptr; + }; + + for (const CharacterSimulationEvent& event : events.characters) { + Character* character = findRuntimeCharacter(event.characterId); + if (character && event.events.navigationRequested) + setCharacterTarget(*character, event.events.navigationTarget); + if (!event.events.arrivedCallbackName.empty()) { + try { + scriptEngine.callFunction(event.events.arrivedCallbackName); + } catch (const std::exception& e) { + std::cerr << "[script] arrival callback error: " << e.what() << "\n"; + } + } + } + for (const CharacterDamageEvent& event : events.damage) { + Character* target = findRuntimeCharacter(event.targetId); + if (target) + target->presentDamage( + event.hp, event.maxHp, event.direction, event.attackerId); + } + + auto eventFor = [&](const std::string& id) -> const CharacterUpdateEvents* { + for (const CharacterSimulationEvent& event : events.characters) + if (event.characterId == id) return &event.events; return nullptr; }; if (player) { - player->update(delta, resolver); + player->update(delta, eventFor("player")); dialogueSystem.update(static_cast(delta)); - updateTriggerZones(player->state.position); + updateTriggerZones(player->characterState.position); } bool npcAttacking = false; for (auto& npc : npcs) { - npc->update(delta, resolver); + npc->update(delta, eventFor(npc->entityId())); - if (npc->state.enabled && npc->state.canAttack && (!npc->state.pathWaypoints.empty() || npc->state.battle_state != 0) && npc->getHp() > 0.f) + if (npc->characterState.enabled && npc->characterState.canAttack && (!npc->characterState.pathWaypoints.empty() || npc->characterState.battle_state != 0) && npc->getHp() > 0.f) { - //logger() << "[NPC] NPC '" << npc->state.npcId << "' is attacking target index " << npc->state.attackTargetIndex << std::endl; + //logger() << "[NPC] NPC '" << npc->entityId() << "' is attacking target '" << npc->characterState.attackTargetId << "'" << std::endl; npcAttacking = true; } } @@ -1725,73 +1573,14 @@ namespace FRG } } - resolveCharacterCollisions(); - updateDynamicReplans(delta); - - const float deltaS = static_cast(delta) / 1000.f; - for (auto& [idx, t] : npcBumpedByPlayerCooldown) t -= deltaS; - for (auto& [idx, t] : npcBumpsPlayerCooldown) t -= deltaS; - - // Check if player reached target interactive object - if (auto* obj = getTargetInteractiveObject(); - !(areInteractiveObjectsLocked && areInteractiveObjectsLocked()) && obj && player && !obj->state.isAnimating) - { - const Eigen::Vector3f& approachTarget = obj->state.hasInteractionPosition - ? obj->state.interactionPosition - : obj->state.position; - float distToObject = (player->state.position - approachTarget).norm(); - - // If player is close enough to pick up the item - if (distToObject <= obj->state.approachRadius) { - logger() << "[PICKUP] Player reached object! Distance: " << distToObject << std::endl; - logger() << "[PICKUP] Calling Lua callback for: " << obj->loadedObject.name << std::endl; - - // Call custom activate function if specified, otherwise use fallback - try { - if (!obj->state.activateFunctionName.empty()) { - logger() << "[PICKUP] Using custom function: " << obj->state.activateFunctionName << std::endl; - scriptEngine.callActivateFunction(obj->state.activateFunctionName); - } - else { - logger() << "[PICKUP] Using fallback callback" << std::endl; - scriptEngine.callItemPickupCallback(obj->loadedObject.name); - } - } - catch (const std::exception& e) { - std::cerr << "[PICKUP] Error calling function: " << e.what() << std::endl; - } - - state.targetInteractiveObjectIndex = -1; - } - } - - // Check if player reached target NPC for interaction. - if (state.targetInteractNpcIndex >= 0 && player) { - float distToNpc = (player->state.position - npcs[state.targetInteractNpcIndex]->state.position).norm(); - if (distToNpc <= NPC_TALK_DISTANCE) { - logger() << "[NPC] Player reached NPC index " << state.targetInteractNpcIndex - << " (distance " << distToNpc << "); firing on_npc_interact" << std::endl; - // Stop the player at the talk distance and have the NPC turn to face them. - player->setTarget(player->state.position); - npcs[state.targetInteractNpcIndex]->state.faceTargetIndex = CharacterState::kPlayerIndex; - try { - scriptEngine.callNpcInteractCallback(state.targetInteractNpcIndex); - } - catch (const std::exception& e) { - std::cerr << "[NPC] callback error: " << e.what() << std::endl; - } - state.targetInteractNpcIndex = -1; - } - } - for (auto& tz : teleportZones) tz.update(static_cast(delta)); if (auto* tz = getTargetTeleportZone(); tz && player) { - float dist = (player->state.position - tz->position).norm(); + float dist = (player->characterState.position - tz->position).norm(); if (dist <= tz->radius) { logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl; if (onTeleport) onTeleport(tz->destinationLocation, tz->destinationPosition, tz->destinationRotationY); - state.targetTeleportZoneIndex = -1; + state.targetTeleportZoneId.clear(); return; } } @@ -1800,7 +1589,7 @@ namespace FRG for (auto& tz : teleportZones) { if (!tz.active || !tz.automatic) continue; const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius; - const float dist = (player->state.position - tz.position).norm(); + const float dist = (player->characterState.position - tz.position).norm(); if (!tz.automaticInitialized) { // If the player starts inside the zone (e.g. just arrived here via teleport), @@ -1822,131 +1611,6 @@ namespace FRG } } - updateNpcCar(delta); - } - - void Location::updateNpcCar(int64_t deltaMs) - { - const float dt = static_cast(deltaMs) / 1000.0f; - - NpcCar& car = state.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 = car.currentWaypoint + 1; - logger() << "Waypoint current: " << car.currentWaypoint << std::endl; - if (car.currentWaypoint == car.waypoints.size()) - { - logger() << "waypointsOver" << std::endl; - waypointsOver = true; - } - } - - if (waypointsOver) { - throttle = 0.f; - hasTarget = false; - car.mode = NpcCar::Mode::NONE; - } - else - { - target = car.waypoints[car.currentWaypoint]; - throttle = 1.0f; - hasTarget = true; - } - } - } - else - { - throttle = 0.f; - } - - 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 > M_PI) angleDiff -= 2.0f * static_cast(M_PI); - while (angleDiff < -M_PI) angleDiff += 2.0f * static_cast(M_PI); - - const float turnIntent = max(-1.0f, min(1.0f, angleDiff * 2.0f)); - const float targetSteer = turnIntent * NpcCar::maxSteerAngle; - const float steerLerp = min(1.f, dt * 8.f); - car.steeringAngle += (targetSteer - car.steeringAngle) * steerLerp; - - if (throttle > 0.f) { - car.velocity += NpcCar::acceleration * dt * throttle; - } - else if (throttle < 0.f) { - const float brakeForce = NpcCar::acceleration * dt * (-throttle); - if (car.velocity > 0.f) { - car.velocity = max(0.f, car.velocity - brakeForce); - } - else { - car.velocity = min(0.f, car.velocity + brakeForce); - } - } - else { - const float resistance = NpcCar::friction * dt; - if (car.velocity > 0.f) { - car.velocity = max(0.f, car.velocity - resistance); - } - else if (car.velocity < 0.f) { - car.velocity = min(0.f, car.velocity + resistance); - } - } - car.velocity = max(-NpcCar::maxReverseSpeed, min(NpcCar::maxSpeed, car.velocity)); - - const float oldRotation = car.rotation; - if (std::abs(car.velocity) > 0.01f) { - const float speedFactor = car.velocity / NpcCar::maxSpeed; - car.rotation += turnIntent * NpcCar::turnRate * dt * speedFactor; - } - /* - if (navigation.isReady() && !isCarFootprintWalkable(car.position, car.rotation)) { - car.rotation = oldRotation; - } - */ - 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(); - const float maxSubstep = 0.2f; - const int substeps = max(1, static_cast(std::ceil(totalDist / maxSubstep))); - const Eigen::Vector3f stepDelta = totalDelta / static_cast(substeps); - for (int s = 0; s < substeps; ++s) { - Eigen::Vector3f candidate = car.position + stepDelta; - car.position = candidate; - } - - state.taxiCar.wheelAngle += car.velocity * 4.f * dt; - while (state.taxiCar.wheelAngle > 2.0f * static_cast(M_PI)) { - state.taxiCar.wheelAngle -= 2.0f * static_cast(M_PI); - } - - if (state.taxiArrivedCallbackCalled == false) - { - Eigen::Vector3f toTarget = state.taxiCar.position - state.taxiCarTriggerPosition; - if (toTarget.squaredNorm() < 9.f) { - scriptEngine.callCallTaxiCallback(); - state.taxiArrivedCallbackCalled = true; - } - } } void Location::handleDown(int64_t fingerId, int eventX, int eventY, int mx, int my) @@ -1957,11 +1621,11 @@ namespace FRG return; } - int lastPlayerAttackTarget = player->state.attackTargetIndex; + const std::string lastPlayerAttackTarget = player->characterState.attackTargetId; - player->state.attackTargetIndex = CharacterState::kNoTarget; + clearCharacterAttackTarget(*player); - // Unproject click to ground plane (y=0) for Viola's walk target + // Unproject click to ground plane for walk target float ndcX = 2.0f * eventX / Environment::width - 1.0f; float ndcY = 1.0f - 2.0f * eventY / Environment::height; float aspect = (float)Environment::width / (float)Environment::height; @@ -1973,7 +1637,7 @@ namespace FRG Eigen::Vector3f camRight(cosAzim, 0.f, sinAzim); Eigen::Vector3f camForward(sinAzim * cosIncl, -sinIncl, -cosAzim * cosIncl); Eigen::Vector3f camUp(sinAzim * sinIncl, cosIncl, -cosAzim * sinIncl); - const Eigen::Vector3f& playerPos = player ? player->state.position : Eigen::Vector3f::Zero(); + const Eigen::Vector3f& playerPos = player ? player->characterState.position : Eigen::Vector3f::Zero(); Eigen::Vector3f camPos = playerPos + Eigen::Vector3f(-sinAzim * cosIncl, sinIncl, cosAzim * cosIncl) * Environment::zoom + Eigen::Vector3f(0,1,0); Eigen::Vector3f rayDir = (camForward + camRight * (ndcX * aspect * tanHalfFov) + camUp * (ndcY * tanHalfFov)).normalized(); @@ -2009,22 +1673,22 @@ namespace FRG InteractiveObject* clickedObject = raycastInteractiveObjects(camPos, rayDir); if (clickedObject && player && clickedObject->state.isActive) { //logger() << "[CLICK] *** SUCCESS: Clicked on interactive object: " << clickedObject->loadedObject.name << " ***" << std::endl; - //logger() << "[CLICK] Object position: (" << clickedObject->state.position.x() << ", " - // << clickedObject->state.position.y() << ", " << clickedObject->state.position.z() << ")" << std::endl; - //logger() << "[CLICK] Player position: (" << player->state.position.x() << ", " - // << player->state.position.y() << ", " << player->state.position.z() << ")" << std::endl; + //logger() << "[CLICK] Object position: (" << clickedObject->characterState.position.x() << ", " + // << clickedObject->characterState.position.y() << ", " << clickedObject->characterState.position.z() << ")" << std::endl; + //logger() << "[CLICK] Player position: (" << player->characterState.position.x() << ", " + // << player->characterState.position.y() << ", " << player->characterState.position.z() << ")" << std::endl; - state.targetInteractiveObjectIndex = findInteractiveObjectIndex(clickedObject); - state.targetInteractNpcIndex = -1; - state.targetTeleportZoneIndex = -1; + state.targetInteractiveObjectId = clickedObject->entityId(); + state.targetInteractNpcId.clear(); + state.targetTeleportZoneId.clear(); const Eigen::Vector3f interactionTarget = clickedObject->state.hasInteractionPosition ? clickedObject->state.interactionPosition : clickedObject->state.position; - player->setTarget(interactionTarget); - if (!player->state.pathWaypoints.empty()) { + setCharacterTarget(*player, interactionTarget); + if (!player->characterState.pathWaypoints.empty()) { pathVisualization.start(*player, interactionTarget); } - player->state.attackTargetIndex = CharacterState::kNoTarget; + clearCharacterAttackTarget(*player); //logger() << "[CLICK] Player moving to object..." << std::endl; } else { @@ -2033,7 +1697,7 @@ namespace FRG // Check if we clicked on an NPC Character* clickedNpc = raycastNpcs(camPos, rayDir); if (clickedNpc && player) { - float distance = (player->state.position - clickedNpc->state.position).norm(); + float distance = (player->characterState.position - clickedNpc->characterState.position).norm(); int npcIndex = -1; for (size_t i = 0; i < npcs.size(); ++i) { if (npcs[i].get() == clickedNpc) { @@ -2043,57 +1707,62 @@ namespace FRG } if (npcIndex != -1) { //logger() << "Raycast npc step 2" << std::endl; - state.targetInteractiveObjectIndex = -1; - state.targetTeleportZoneIndex = -1; + state.targetInteractiveObjectId.clear(); + state.targetTeleportZoneId.clear(); - if (clickedNpc->state.canAttack) { + if (clickedNpc->characterState.canAttack) { //logger() << "Raycast npc step 3" << std::endl; - //logger() << "player->state.attackTargetIndex=" << player->state.attackTargetIndex << std::endl; + //logger() << "player target=" << player->characterState.attackTargetId << std::endl; //logger() << "npcIndex=" << npcIndex << std::endl; - // Hostile NPC: combat logic walks the player in via attackTargetIndex. - if (lastPlayerAttackTarget == npcIndex) + // Hostile NPC: combat logic walks the player toward the stable target ID. + if (lastPlayerAttackTarget == clickedNpc->entityId()) { //logger() << "Raycast npc step 4" << std::endl; - player->tryAttackCombo(); + tryCharacterAttackCombo(*player); } - player->state.attackTargetIndex = npcIndex; - state.targetInteractNpcIndex = -1; + const std::string targetId = clickedNpc->entityId(); + updateCharacterState(*player, [targetId](CharacterState& workingState) { + workingState.attackTargetId = targetId; + }); + state.targetInteractNpcId.clear(); if (distance >= Character::kAttackRange) { - player->setTarget(clickedNpc->state.position); - if (!player->state.pathWaypoints.empty()) { + setCharacterTarget(*player, clickedNpc->characterState.position); + if (!player->characterState.pathWaypoints.empty()) { pathVisualization.start( *player, - clickedNpc->state.position, + clickedNpc->characterState.position, PathVisualization::Style::Hostile); } } - if (distance <= clickedNpc->state.interactionRadius) { + if (distance <= clickedNpc->characterState.interactionRadius) { //logger() << "[CLICK] Hostile NPC " << npcIndex << " in range; firing on_npc_interact" << std::endl; - scriptEngine.callNpcInteractCallback(npcIndex); + scriptEngine.callNpcInteractCallback(clickedNpc->entityId()); } } else { // Peaceful NPC: walk to them, fire on_npc_interact when within talk distance. - player->state.attackTargetIndex = CharacterState::kNoTarget; + clearCharacterAttackTarget(*player); if (distance <= NPC_TALK_DISTANCE) { // Already in talk range — fire immediately, stop, and face the player. //logger() << "[CLICK] *** SUCCESS: Clicked on NPC index: " << npcIndex << " (in range) ***" << std::endl; - player->setTarget(player->state.position); - npcs[npcIndex]->state.faceTargetIndex = CharacterState::kPlayerIndex; - scriptEngine.callNpcInteractCallback(npcIndex); - state.targetInteractNpcIndex = -1; + setCharacterTarget(*player, player->characterState.position); + updateCharacterState(*npcs[npcIndex], [](CharacterState& workingState) { + workingState.faceTargetId = "player"; + }); + scriptEngine.callNpcInteractCallback(clickedNpc->entityId()); + state.targetInteractNpcId.clear(); } else { //logger() << "[CLICK] NPC " << npcIndex << " out of talk range (distance " << distance // << " > " << NPC_TALK_DISTANCE << "); walking to NPC..." << std::endl; - player->setTarget(clickedNpc->state.position); - if (!player->state.pathWaypoints.empty()) { - pathVisualization.start(*player, clickedNpc->state.position); + setCharacterTarget(*player, clickedNpc->characterState.position); + if (!player->characterState.pathWaypoints.empty()) { + pathVisualization.start(*player, clickedNpc->characterState.position); } - state.targetInteractNpcIndex = npcIndex; + state.targetInteractNpcId = clickedNpc->entityId(); } } } @@ -2114,24 +1783,24 @@ namespace FRG if (clickedTeleport) { //logger() << "[CLICK] Clicked teleport zone '" << clickedTeleport->id << "'" << std::endl; - state.targetTeleportZoneIndex = findTeleportZoneIndex(clickedTeleport); - state.targetInteractiveObjectIndex = -1; - state.targetInteractNpcIndex = -1; - player->setTarget(clickedTeleport->position); - if (!player->state.pathWaypoints.empty()) { + state.targetTeleportZoneId = clickedTeleport->id; + state.targetInteractiveObjectId.clear(); + state.targetInteractNpcId.clear(); + setCharacterTarget(*player, clickedTeleport->position); + if (!player->characterState.pathWaypoints.empty()) { pathVisualization.start( *player, clickedTeleport->position, PathVisualization::Style::Teleport); } - player->state.attackTargetIndex = CharacterState::kNoTarget; + clearCharacterAttackTarget(*player); } else { const Eigen::Vector3f clickedDestination(hit.x(), 0.f, hit.z()); - player->setTarget(clickedDestination); + setCharacterTarget(*player, clickedDestination); pathVisualization.start(*player, clickedDestination); - player->state.attackTargetIndex = CharacterState::kNoTarget; - state.targetInteractNpcIndex = -1; - state.targetTeleportZoneIndex = -1; + clearCharacterAttackTarget(*player); + state.targetInteractNpcId.clear(); + state.targetTeleportZoneId.clear(); if (onPlayerFloorWalk) onPlayerFloorWalk(); } } @@ -2199,195 +1868,33 @@ namespace FRG return dialogueSystem.getFlag(flag); } - void Location::onTaxiCalled() + void Location::captureAuxiliaryState(LocationGameState& out) const { - state.taxiCar.waypoints = state.taxiCarWaypoints; - state.taxiCar.mode = NpcCar::Mode::FOLLOW_WAYPOINTS; - - // For the dorm - disable trigger zone because the player already ordered taxi - for (auto& tz : triggerZones) { - if (tz.id == "taxi_zone001") { - tz.enabled = false; - break; - } - } - + out.hasPersistentState = true; + dialogueSystem.saveDialogueState(out.dialogue); + scriptEngine.saveScriptGlobals(out.scriptGlobals); + out.triggerZones.clear(); + for (const auto& zone : triggerZones) + out.triggerZones[zone.id] = {zone.enabled, zone.playerInside}; + out.teleportZones.clear(); + for (const auto& zone : teleportZones) out.teleportZones[zone.id] = zone.active; } - // ---- Save / Load ---- - - void Location::save(nlohmann::json& out) const + void Location::applyAuxiliaryState(const LocationGameState& in) { - // Location-level state - nlohmann::json locState; - state.save(locState); - out["locationState"] = std::move(locState); - - // Player character state - if (player) { - nlohmann::json ps; - player->state.save(ps); - out["playerState"] = std::move(ps); - } - - // NPC states - nlohmann::json npcArr = nlohmann::json::array(); - for (const auto& npc : npcs) { - if (npc) { - nlohmann::json ns; - npc->state.save(ns); - npcArr.push_back(std::move(ns)); - } else { - npcArr.push_back(nullptr); + dialogueSystem.loadDialogueState(in.dialogue); + scriptEngine.loadScriptGlobals(in.scriptGlobals); + for (auto& zone : triggerZones) { + auto stateIt = in.triggerZones.find(zone.id); + if (stateIt != in.triggerZones.end()) { + zone.enabled = stateIt->second.enabled; + zone.playerInside = stateIt->second.playerInside; } } - out["npcStates"] = std::move(npcArr); - - // Interactive object states - nlohmann::json ioArr = nlohmann::json::array(); - for (const auto& intObj : interactiveObjects) { - nlohmann::json ios; - intObj.state.save(ios); - ioArr.push_back(std::move(ios)); + for (auto& zone : teleportZones) { + auto stateIt = in.teleportZones.find(zone.id); + if (stateIt != in.teleportZones.end()) zone.active = stateIt->second; } - out["interactiveObjectStates"] = std::move(ioArr); - - // Dialogue runtime state - nlohmann::json dialogueState; - dialogueSystem.saveDialogueState(dialogueState); - out["dialogueState"] = std::move(dialogueState); - - // Lua global variable state - nlohmann::json scriptState; - scriptEngine.saveScriptGlobals(scriptState); - out["scriptGlobals"] = std::move(scriptState); - - // Trigger zone runtime state (by id) - nlohmann::json tzState = nlohmann::json::object(); - for (const auto& tz : triggerZones) { - tzState[tz.id] = { {"enabled", tz.enabled}, {"playerInside", tz.playerInside} }; - } - out["triggerZoneState"] = std::move(tzState); - - // Teleport zone runtime state (by id) - nlohmann::json tpState = nlohmann::json::object(); - for (const auto& tp : teleportZones) { - tpState[tp.id] = tp.active; - } - out["teleportZoneState"] = std::move(tpState); - } - - void Location::load(const nlohmann::json& in) - { - //logger() << "Location::load step 1" << std::endl; - - // Location-level state - if (in.contains("locationState")) { - state.load(in["locationState"]); - } - //logger() << "Location::load step 2" << std::endl; - - // Re-sync navigation pointer from restored index - if (state.activeNavigationIndex >= 0 && - state.activeNavigationIndex < static_cast(navigationMaps.size())) - { - navigation = &navigationMaps[state.activeNavigationIndex]; - } - - //logger() << "Location::load step 3" << std::endl; - - // Restore trigger zone runtime state (by id) - if (in.contains("triggerZoneState") && in["triggerZoneState"].is_object()) { - const auto& tzs = in["triggerZoneState"]; - for (auto& tz : triggerZones) { - if (tzs.contains(tz.id)) { - tz.enabled = tzs[tz.id].value("enabled", tz.enabled); - tz.playerInside = tzs[tz.id].value("playerInside", tz.playerInside); - } - } - } - //logger() << "Location::load step 4" << std::endl; - - // Restore teleport zone runtime state (by id) - if (in.contains("teleportZoneState") && in["teleportZoneState"].is_object()) { - const auto& tps = in["teleportZoneState"]; - for (auto& tp : teleportZones) { - if (tps.contains(tp.id)) { - tp.active = tps[tp.id].get(); - } - } - } - - //logger() << "Location::load step 5" << std::endl; - - // Player character state - if (player && in.contains("playerState")) { - player->state.load(in["playerState"]); - } - - //logger() << "Location::load step 6" << std::endl; - - // NPC states - if (in.contains("npcStates") && in["npcStates"].is_array()) { - const auto& arr = in["npcStates"]; - for (int i = 0; i < static_cast(arr.size()) && i < static_cast(npcs.size()); ++i) { - if (npcs[i] && !arr[i].is_null()) { - npcs[i]->state.load(arr[i]); - } - } - } - - //logger() << "Location::load step 7" << std::endl; - - // Interactive object states - if (in.contains("interactiveObjectStates") && in["interactiveObjectStates"].is_array()) { - const auto& arr = in["interactiveObjectStates"]; - for (int i = 0; i < static_cast(arr.size()) && i < static_cast(interactiveObjects.size()); ++i) { - interactiveObjects[i].state.load(arr[i]); - } - } - //logger() << "Location::load step 8" << std::endl; - - // Dialogue runtime state - if (in.contains("dialogueState")) { - dialogueSystem.loadDialogueState(in["dialogueState"]); - } - - //logger() << "Location::load step 9" << std::endl; - - // Lua globals - if (in.contains("scriptGlobals")) { - scriptEngine.loadScriptGlobals(in["scriptGlobals"]); - } - - //logger() << "Location::load step 10" << std::endl; - - - // Re-wire in-flight, one-shot Lua callbacks (walk-arrival / animation-complete) - // that were serialized by name. Must run after scriptEngine has already run the - // location's script (guaranteed: Location::setup() always runs before load()) - // and after the states above are restored. - if (player && !player->state.onArrivedCallbackName.empty()) { - scriptEngine.rehydrateNamedCallback(player->state.onArrivedCallbackName, player->state.onArrivedCallback); - } - - //logger() << "Location::load step 11" << std::endl; - - for (auto& npc : npcs) { - if (npc && !npc->state.onArrivedCallbackName.empty()) { - scriptEngine.rehydrateNamedCallback(npc->state.onArrivedCallbackName, npc->state.onArrivedCallback); - } - } - //logger() << "Location::load step 12" << std::endl; - - for (auto& intObj : interactiveObjects) { - if (intObj.state.isAnimating && intObj.state.animTask && !intObj.state.animTask->onCompleteName.empty()) { - scriptEngine.rehydrateNamedCallback(intObj.state.animTask->onCompleteName, intObj.state.animTask->onComplete); - } - } - - //logger() << "Location::load step 13" << std::endl; - } } // namespace FRG diff --git a/src/Location.h b/src/Location.h index 532524b..41142ea 100644 --- a/src/Location.h +++ b/src/Location.h @@ -16,13 +16,15 @@ #include "TeleportZone.h" #include "LocationEditor.h" #include "LocationState.h" -#include "ISaveable.h" +#include "LocationGameState.h" #include #include #include +#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 navigationJsonPaths; + //std::vector 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 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 gameObjects; @@ -88,10 +90,11 @@ namespace FRG std::vector> npcs; // Builds the player character (models, animations, weapon, textures). - static std::shared_ptr createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer); + static std::shared_ptr createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer, + CharacterState& state, IGameServer& server); - std::vector navigationMaps; - PathFinder* navigation = nullptr; + //std::vector navigationMaps; + //int state.activeNavigationIndex = 0; std::shared_ptr shadowMap; Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity(); @@ -114,10 +117,6 @@ namespace FRG std::function 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 areInteractiveObjectsLocked; - // Set by Game after setup(). Lua and onDeathAnimComplete call this to // request the transition without coupling Location to Game directly. std::function requestDarklandsTransition; @@ -140,7 +139,7 @@ namespace FRG EditorMode editorMode = EditorMode::None; LocationEditor editor; - std::vector navigationMapPaths; + //std::vector 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& paths); + //void setupNavigation(const std::vector& paths); bool switchNavigation(int index); + void setCharacterTarget(Character& character, const Eigen::Vector3f& target, + std::string callbackName = ""); + void updateCharacterState( + Character& character, + const std::function& 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& 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 lastCharacterPositions; - std::unordered_map replanCooldownRemainingMs; - std::unordered_map stuckFrameCounts; - std::unordered_map stuckPauseRemainingS; - - static constexpr float NPC_BUMP_CALLBACK_COOLDOWN = 5.0f; - std::unordered_map npcBumpedByPlayerCooldown; - std::unordered_map npcBumpsPlayerCooldown; }; } // namespace FRG diff --git a/src/LocationEditor.cpp b/src/LocationEditor.cpp index 81545d9..87f1153 100644 --- a/src/LocationEditor.cpp +++ b/src/LocationEditor.cpp @@ -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; diff --git a/src/LocationGameState.cpp b/src/LocationGameState.cpp new file mode 100644 index 0000000..decb251 --- /dev/null +++ b/src/LocationGameState.cpp @@ -0,0 +1,351 @@ +#include "LocationGameState.h" +#include +#include +#include + +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(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(std::ceil(totalDist / maxSubstep))); + const Eigen::Vector3f stepDelta = totalDelta / static_cast(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 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 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 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 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(); + 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(); + 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(); + 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(), + value.at("playerInside").get()}; + 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(); +} + +} // namespace FRG diff --git a/src/LocationGameState.h b/src/LocationGameState.h new file mode 100644 index 0000000..d74fa69 --- /dev/null +++ b/src/LocationGameState.h @@ -0,0 +1,75 @@ +#pragma once + +#include "CharacterState.h" +#include "LocationState.h" +#include "items/InteractiveObjectState.h" +#include "external/nlohmann/json.hpp" +#include +#include +#include + +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 characters; + std::vector damage; + std::vector 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 location = std::make_shared(); + std::unordered_map> npcs; + std::unordered_map> interactiveObjects; + bool lockInteractiveObjectsDuringTutorial = false; + bool hasPersistentState = false; + std::unordered_map triggerZones; + std::unordered_map 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 diff --git a/src/LocationState.cpp b/src/LocationState.cpp index 62a4ccf..3cd4b61 100644 --- a/src/LocationState.cpp +++ b/src/LocationState.cpp @@ -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(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(), + position.at(1).get(), + position.at(2).get()}; + car.rotation = value.at("rotation").get(); + car.velocity = value.at("velocity").get(); + car.steeringAngle = value.at("steeringAngle").get(); + car.wheelAngle = value.at("wheelAngle").get(); + car.mode = static_cast(value.at("mode").get()); + car.waypoints.clear(); + for (const auto& waypoint : value.at("waypoints")) + car.waypoints.push_back({ + waypoint.at("x").get(), + waypoint.at("y").get(), + waypoint.at("z").get()}); + car.currentWaypoint = value.at("currentWaypoint").get(); + } + } + + + std::vector 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& 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(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(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(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(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(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(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(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(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(); } } // namespace FRG diff --git a/src/LocationState.h b/src/LocationState.h index 24077d7..da08f40 100644 --- a/src/LocationState.h +++ b/src/LocationState.h @@ -2,10 +2,18 @@ #include "external/nlohmann/json.hpp" #include "ISaveable.h" #include "NpcCar.h" +#include "navigation/PathFinder.h" +#include 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 navigationMaps; + std::vector planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const; + void initNavigation(const std::vector& 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; diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index 2f4242d..28abb8d 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -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 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 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(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 MenuManager::inventoryItems() const { + std::vector 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 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(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(gameState_.playerHp)) + "/" + - std::to_string(static_cast(gameState_.playerMaxHp)); + std::string hpText = std::to_string(static_cast(state->playerHp)) + "/" + + std::to_string(static_cast(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(inventory->getItems().size()); + const int itemCount = static_cast(inventoryItems().size()); if (isPortraitMode() && wasOnItemDetail && savedIndex >= 0 && savedIndex < itemCount) { selectInventoryItem(savedIndex); diff --git a/src/MenuManager.h b/src/MenuManager.h index 3a354a0..8d9b69b 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -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 #include #include @@ -39,9 +40,9 @@ namespace FRG { std::unordered_map> 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 startGameFunc; @@ -89,7 +90,6 @@ namespace FRG { std::function startNightTransitionFunc; std::function chatOpenCallback; std::function skipCutsceneFunc; - std::function callTaxiFunc; std::function onSaveGame; std::function onLoadGame; std::function 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 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 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; diff --git a/src/ScriptEngine.cpp b/src/ScriptEngine.cpp index b80a07d..ba78bb5 100644 --- a/src/ScriptEngine.cpp +++ b/src/ScriptEngine.cpp @@ -1,5 +1,6 @@ -#include "ScriptEngine.h" +#include "ScriptEngine.h" #include "Game.h" +#include #include #include #include @@ -14,26 +15,11 @@ namespace FRG { namespace { - // Wraps a sol::protected_function into a std::function, 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 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 triggerEnterCallbacks; std::unordered_map triggerExitCallbacks; std::unordered_map cutsceneCompleteCallbacks; - std::unordered_map npcBumpedByPlayerCallbacks; - std::unordered_map npcBumpsPlayerCallbacks; + std::unordered_map npcBumpedByPlayerCallbacks; + std::unordered_map npcBumpsPlayerCallbacks; std::unordered_map* globalInts = nullptr; std::unordered_map* 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(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 cb; std::string cbName; if (on_arrived.is()) { sol::protected_function fn = on_arrived.as(); - 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(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(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(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(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 cb; std::string cbName; if (on_arrived.is()) { sol::protected_function fn = on_arrived.as(); - 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(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(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: " <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()) this_impl->triggerEnterCallbacks[zoneId] = onEnter.as(); if (onExit.is()) @@ -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()) + this_impl->locationInitCallback = onInit.as(); if (onEnter.is()) this_impl->locationEnterCallback = onEnter.as(); if (onExit.is()) @@ -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(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(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(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(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(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(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(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(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(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 cb; - std::string cbName; - if (onComplete.is()) { - sol::protected_function fn = onComplete.as(); - 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 fn = onComplete.as(); + 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 cb; - std::string cbName; - if (onComplete.is()) { - sol::protected_function fn = onComplete.as(); - 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(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 fn = onComplete.as(); + 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(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 cb; - std::string cbName; - if (onComplete.is()) { - sol::protected_function fn = onComplete.as(); - 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 fn = onComplete.as(); + 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 cb; - std::string cbName; - if (onComplete.is()) { - sol::protected_function fn = onComplete.as(); - 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 fn = onComplete.as(); + 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(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()) - this_impl->npcBumpedByPlayerCallbacks[index] = onPlayerBumpsNpc.as(); + this_impl->npcBumpedByPlayerCallbacks[npcId] = onPlayerBumpsNpc.as(); if (onNpcBumpsPlayer.is()) - this_impl->npcBumpsPlayerCallbacks[index] = onNpcBumpsPlayer.as(); + this_impl->npcBumpsPlayerCallbacks[npcId] = onNpcBumpsPlayer.as(); }); // 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& 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 diff --git a/src/ScriptEngine.h b/src/ScriptEngine.h index 95587b6..79214a5 100644 --- a/src/ScriptEngine.h +++ b/src/ScriptEngine.h @@ -2,7 +2,6 @@ #include #include #include -#include #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* store); void setGlobalFloatStore(std::unordered_map* 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& 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; diff --git a/src/SparkEmitter.h b/src/SparkEmitter.h index e9714ef..4f9208c 100644 --- a/src/SparkEmitter.h +++ b/src/SparkEmitter.h @@ -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); diff --git a/src/UiManager.cpp b/src/UiManager.cpp index 9a48413..944db66 100644 --- a/src/UiManager.cpp +++ b/src/UiManager.cpp @@ -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 { diff --git a/src/dialogue/DialogueSystem.h b/src/dialogue/DialogueSystem.h index 432d2ca..e17960a 100644 --- a/src/dialogue/DialogueSystem.h +++ b/src/dialogue/DialogueSystem.h @@ -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(); } diff --git a/src/game_server/IGameServer.h b/src/game_server/IGameServer.h new file mode 100644 index 0000000..19fa227 --- /dev/null +++ b/src/game_server/IGameServer.h @@ -0,0 +1,227 @@ +#pragma once + +#include "../GameState.h" +#include "../external/nlohmann/json.hpp" +#include +#include +#include +#include +#include + +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 npcs; + std::unordered_map interactiveObjects; + std::unordered_map triggerZones; + std::unordered_map 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 chatHistory[3]; + std::vector inventoryItemIds; + nlohmann::json questJournal; + std::unordered_map globalInts; + std::unordered_map globalFloats; + std::unordered_map players; + std::unordered_map locations; + std::vector 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 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 diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp new file mode 100644 index 0000000..524a12f --- /dev/null +++ b/src/game_server/InternalGameServer.cpp @@ -0,0 +1,295 @@ +#include "InternalGameServer.h" +#include "../Location.h" +#include +#include + +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()); + } + 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 InternalGameServer::snapshot() const +{ + if (!cachedSnapshot_ || cachedRevision_ != revision_) { + cachedSnapshot_ = std::make_shared(buildSnapshot()); + cachedRevision_ = revision_; + } + return cachedSnapshot_; +} + +void InternalGameServer::submit(const GameCommand& command) +{ + std::visit([this](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + state_.isDarklands = value.darklands; + state_.isNight = value.night; + state_.isDawn = value.dawn; + } else if constexpr (std::is_same_v) { + state_.currentLocationName = value.locationId; + } else if constexpr (std::is_same_v) { + state_.playerHp = value.hp; + state_.playerMaxHp = value.maxHp; + } else if constexpr (std::is_same_v) { + state_.money = value.amount; + } else if constexpr (std::is_same_v) { + state_.money -= value.amount; + } else if constexpr (std::is_same_v) { + state_.completeTutorialStep(value.step); + } else if constexpr (std::is_same_v) { + state_.tutorialStep = value.step; + } else if constexpr (std::is_same_v) { + state_.tutorialPhoneChatScreenOpened = value.phoneChatOpened; + state_.tutorialJournalScreenOpened = value.journalOpened; + state_.tutorialNeedOpenTaxiScreen = value.needTaxiHint; + state_.tutorialTaxiHintDone = value.taxiHintDone; + } else if constexpr (std::is_same_v) { + 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) { + 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) { + state_.taxiIsCalled = value.called; + } else if constexpr (std::is_same_v) { + auto location = state_.locations.find(value.locationId); + if (location != state_.locations.end()) + location->second.startTaxi(); + } else if constexpr (std::is_same_v) { + state_.inventory.addItem(value.itemId); + } else if constexpr (std::is_same_v) { + state_.inventory.removeItem(value.itemId); + } else if constexpr (std::is_same_v) { + state_.globalInts[value.key] = value.value; + } else if constexpr (std::is_same_v) { + state_.globalFloats[value.key] = value.value; + } else if constexpr (std::is_same_v) { + 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) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->attackTargetId = value.targetId; + } else if constexpr (std::is_same_v) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->attackTargetId.clear(); + } else if constexpr (std::is_same_v) { + 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) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->stopInPlace(); + } else if constexpr (std::is_same_v) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) + character->stopAndRotateToTarget(value.targetId); + } else if constexpr (std::is_same_v) { + 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) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->resetPlayerAfterDeath(); + } else if constexpr (std::is_same_v) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->tryAttackCombo(); + } else if constexpr (std::is_same_v) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) character->setHp(value.hp); + } else if constexpr (std::is_same_v) { + 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) { + 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) { + CharacterState* character = + state_.findCharacter(value.locationId, value.characterId); + if (character) *character = value.state; + } else if constexpr (std::is_same_v) { + 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) { + 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) { + 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(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) +{ + 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 diff --git a/src/game_server/InternalGameServer.h b/src/game_server/InternalGameServer.h new file mode 100644 index 0000000..c7a18ad --- /dev/null +++ b/src/game_server/InternalGameServer.h @@ -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 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>& runtimeLocations() { return runtimeLocations_; } + const std::unordered_map>& runtimeLocations() const { return runtimeLocations_; } + void bindRuntimeLocation(const std::string& id, std::shared_ptr location); + void synchronizeRuntimeAuxiliaryState(); + void applyStateToRuntime(); + void markHostMutation(); + + GameSnapshot buildSnapshot() const; + + GameState state_; + std::unordered_map> runtimeLocations_; + std::uint64_t revision_ = 1; + mutable std::uint64_t cachedRevision_ = 0; + mutable std::shared_ptr cachedSnapshot_; +}; + +} // namespace FRG diff --git a/src/items/GameObjectLoader.cpp b/src/items/GameObjectLoader.cpp index 59bf762..72257da 100644 --- a/src/items/GameObjectLoader.cpp +++ b/src/items/GameObjectLoader.cpp @@ -8,6 +8,7 @@ #include #include "../Character.h" #include +#include namespace FRG { @@ -91,6 +92,9 @@ namespace FRG { for (const auto& item : j["objects"]) { InteractiveObjectData data; + data.id = item.at("id").get(); + 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 GameObjectLoader::loadAndCreateInteractiveObjects( const std::string& jsonPath, Renderer& renderer, + std::unordered_map>& states, const std::string& zipPath) { std::vector 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 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(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(); + 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> GameObjectLoader::loadAndCreate_Npcs( const std::string& jsonPath, Renderer& renderer, + std::unordered_map>& states, + IGameServer& server, + const std::string& locationId, const std::string& zipPath) { std::vector> npcs; - int index = 0; - - for (const auto& npcData : loadNpcsFromJson(jsonPath, zipPath)) { + const auto definitions = loadNpcsFromJson(jsonPath, zipPath); + std::unordered_set 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(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; } diff --git a/src/items/GameObjectLoader.h b/src/items/GameObjectLoader.h index 0574cc2..ea563c3 100644 --- a/src/items/GameObjectLoader.h +++ b/src/items/GameObjectLoader.h @@ -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 loadAndCreateInteractiveObjects( const std::string& jsonPath, Renderer& renderer, + std::unordered_map>& states, const std::string& zipPath = "" ); static std::vector> loadAndCreate_Npcs( const std::string& jsonPath, Renderer& renderer, - const std::string& zipPath = "" + std::unordered_map>& states, + IGameServer& server, + const std::string& locationId, + const std::string& zipPath ); static std::vector loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath = ""); diff --git a/src/items/InteractiveObject.cpp b/src/items/InteractiveObject.cpp index c0445df..3ff55ad 100644 --- a/src/items/InteractiveObject.cpp +++ b/src/items/InteractiveObject.cpp @@ -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 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 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 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 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(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 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); } } diff --git a/src/items/InteractiveObject.h b/src/items/InteractiveObject.h index 8460f90..9484959 100644 --- a/src/items/InteractiveObject.h +++ b/src/items/InteractiveObject.h @@ -1,9 +1,6 @@ #pragma once #include #include -#include -#include -#include #include #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 onComplete = {}, std::string callbackName = ""); - void rotateTo(float targetRotY, float durationSec, std::function onComplete = {}, std::string callbackName = ""); - void scaleTo(float targetScale, float durationSec, std::function onComplete = {}, std::string callbackName = ""); - void fadeTo(float targetAlpha, float durationSec, std::function 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 diff --git a/src/items/InteractiveObjectState.h b/src/items/InteractiveObjectState.h index 521fac1..858ca54 100644 --- a/src/items/InteractiveObjectState.h +++ b/src/items/InteractiveObjectState.h @@ -1,7 +1,7 @@ #pragma once +#include #include #include -#include #include #include #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 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; + 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; diff --git a/src/items/Item.cpp b/src/items/Item.cpp index 3703f8b..18027fb 100644 --- a/src/items/Item.cpp +++ b/src/items/Item.cpp @@ -3,6 +3,7 @@ #include "external/nlohmann/json.hpp" #include #include +#include #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()); - } 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()); } } diff --git a/src/render/PathVisualization.cpp b/src/render/PathVisualization.cpp index c41d9e5..bfb9d6d 100644 --- a/src/render/PathVisualization.cpp +++ b/src/render/PathVisualization.cpp @@ -101,12 +101,12 @@ namespace FRG { } std::vector 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);