diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 79d4bfd..cea95e1 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -219,8 +219,37 @@ cd demos/demo-sokoban # ...same check on a minimal flat-floor scene # (demo_scene_vehicletest.json, symlinked like the two main scenes): ./demoSokoban --headless --test-vehicle demo_scene_vehicletest.json -# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door, -# Escape = pause menu (frees the cursor). +# ...enter/exit check: sends the vehicle_enter event, drives by holding +# the game-mode W input, holds D and asserts the steering wheel visual +# spins, exits and verifies the character is back on foot next to the +# moved forklift: +./demoSokoban --headless --test-vehicle-drive +# ...crate check on the flat-floor scene (settle, impulse kick, slide, +# upright re-settle): +./demoSokoban --headless --test-crate +# ...sokoban rules check on the exterior scene (teleports crates onto +# the pads, asserts zone_entered/zone_left events, the latched +# quest.yard1.completed global state and the quest_completed event; +# registers a second Lua instance mid-run to verify multi-yard +# isolation): +./demoSokoban --headless --test-sokoban +# ...fork check on the flat-floor scene (align with crate1, raise the +# fork - crate attaches and rides up, drive carried, lower - release, +# reverse away leaving the crate behind): +./demoSokoban --headless --test-fork +# ...yard reset check on the exterior scene (displace crate1, move the +# player 150m away, verify the crates teleport back to their slots and +# sokoban_reset fires while the quest stays incomplete): +./demoSokoban --headless --test-sokoban-reset +# ...debug helpers: seat/camera tuning and terrain probing (see +# demos/demo-sokoban/PLAN.md Phase 3/5): +xvfb-run -a ./demoSokoban demo_scene_exterior.json \ + --debug-enter-vehicle forklift1 --screenshot 240 /tmp/seat.png +./demoSokoban --headless --probe-heights 4012 3943 7 1 +# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door / +# drive the forklift, hold E = exit the vehicle, Space = handbrake +# while driving, R/F = raise/lower the fork while driving, Escape = +# pause menu (frees the cursor). # Road wedge/segment self-intersection regression test (no scene needed, @@ -311,33 +340,46 @@ binary + its staged project directory as the distributable. `EditorApp::frameRenderingQueued()` updates systems in this order when not paused: -1. `PlayerControllerSystem` (only in `Playing`) -2. `CharacterSpawnerSystem` -3. `CharacterSlotSystem` -4. `AnimationTreeSystem` / `BehaviorTreeSystem` -5. `PathFollowingSystem` -6. `ProceduralMeshSystem` -7. `TerrainSystem`, then `TerrainPrefabSpawnerSystem` (spawn Y-snap and +1. `VehicleControllerSystem` (only in `Playing`; feeds this frame's drive + inputs into the `VehicleComponent`) +2. `PlayerControllerSystem` (only in `Playing`; the driving-mode camera is + deferred to its `postPhysicsUpdate`, see 21a) +3. `CharacterSpawnerSystem` +4. `CharacterSlotSystem` +5. `AnimationTreeSystem` / `BehaviorTreeSystem` +6. `PathFollowingSystem` +7. `ProceduralMeshSystem` +8. `TerrainSystem`, then `TerrainPrefabSpawnerSystem` (spawn Y-snap and terrain compliance see fresh terrain data) -8. `RoomLayoutSystem` -9. `CellGridSystem` -10. `NormalDebugSystem` -11. `NavMeshSystem` -12. `SmartObjectSystem` -13. `GoapPlannerSystem` -14. `GoapRunnerSystem` -15. `ActuatorSystem` -16. `DoorSystem` -17. `EventHandlerSystem` -18. `CharacterSystem` -19. `BuoyancySystem` -20. `PhysicsSystem` -21. `HairPhysicsSystem` pose read-back -22. Rendering support systems (sun, skybox, water, light, LOD, etc.) +9. `RoomLayoutSystem` +10. `CellGridSystem` +11. `NormalDebugSystem` +12. `NavMeshSystem` +13. `SmartObjectSystem` +14. `GoapPlannerSystem` +15. `GoapRunnerSystem` +16. `ActuatorSystem` +17. `DoorSystem` +18. `EventHandlerSystem` +19. `CharacterSystem` +20. `BuoyancySystem` +21. `PhysicsSystem` (pre: `VehicleSystem::prePhysicsUpdate`; post: + `VehicleSystem::postPhysicsUpdate` wheel/fork/steering visuals) +21a. `VehicleControllerSystem::postPhysicsUpdate` pins the seated + character to the freshly synced vehicle node, then + `PlayerControllerSystem::postPhysicsUpdate` runs the driving-mode + camera from that pin — physics advances in fixed 60 Hz bursts, so + pinning pre-step left the driver one sync behind the vehicle and the + seat visibly jittered under them +22. `HairPhysicsSystem` pose read-back +23. Rendering support systems (sun, skybox, water, light, LOD, etc.) Systems that write animation state or velocity should respect systems that run -before them. In particular, `PlayerControllerSystem` runs first and is the only -system that should drive player locomotion animation. +before them. In particular, `PlayerControllerSystem` runs first among the +gameplay systems and is the only system that should drive player locomotion +animation (when the player is driving, `VehicleControllerSystem` parks the +locomotion state machine in idle and `PlayerControllerSystem` skips +locomotion via `PlayerControllerComponent::driving`). ## Important Subsystems @@ -371,6 +413,13 @@ system that should drive player locomotion animation. `CharacterSlotSystem::setCharacterVisible` (with hysteresis) so the camera never renders the inside of the body; `shutdownController` restores visibility because the character can survive scene switches. +- While `PlayerControllerComponent::driving` is true (set by + `VehicleControllerSystem`), `updateLocomotion` returns early; the camera + update is deferred to `postPhysicsUpdate()` so it reads the character + node after `VehicleControllerSystem::postPhysicsUpdate()` pins it to the + freshly physics-synced vehicle seat (see "Frame Update Order" 21a), and + `tpsDistance`/`tpsHeight` are temporarily swapped for the vehicle's + `cameraDistance`/`cameraHeight`. ### CharacterSpawnerSystem @@ -589,6 +638,8 @@ used by inventory/character-sheet UI, save/load and `ActuatorSystem`. Movement keys are polled each frame with `SDL_PumpEvents()` + `SDL_GetKeyboardState()` to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags (`ePressed`, `fPressed`, `iPressed`) are still set from event handlers. +`space`/`spacePressed` (vehicle handbrake while driving) are also set from the +game-mode key handlers. ### AnimationTreeSystem & Root Motion @@ -615,7 +666,9 @@ action. Actions run through `BehaviorTreeSystem` and lock player input while running (`PlayerControllerComponent::inputLocked`). Targeting/prompting is screen-space via ImGui, so `update()` and `render()` bail out early when there is no ImGui context (headless mode); cooldown timers and the executing-action -block still run. +block still run. While the player is driving a vehicle +(`PlayerControllerComponent::driving`), prompt collection is skipped entirely +— E then means "exit the vehicle" (handled by `VehicleControllerSystem`). ### DoorSystem & CellGrid doors @@ -925,7 +978,23 @@ the body tears the constraint down safely. `VehicleComponent` fields (serialized as the `vehicle` scene key, editable via `ui/VehicleEditor`): `maxTorque`, `maxPitchRollAngleDeg`, -`seatOffset`, `wheelMeshName`, and a `wheels` list — per wheel +`seatOffset`, `cameraDistance` / `cameraHeight` (TPS boom while driving), +`rearSteer` (flips the steer sign for rear-steered vehicles like the +forklift, so D still turns the nose right), `engineBrake` (brake factor +applied automatically when coasting above 0.5 m/s with no throttle — +keeps the forklift from rolling forever), `wheelMeshName`, the steering +wheel visual (`steeringWheelMeshName` — mesh modelled at the origin +with its spin axis on local Y —, `steeringWheelOffset` in chassis +space, `steeringWheelTiltDeg`, `steeringRatio` steering-wheel degrees +per road-wheel degree; VehicleSystem smooths the steer angle and spins +the node around its tilted local Y, empty mesh name = no visual), the +fork lift (`forkMeshName` — mesh modelled at the fork-down position, +e.g. `forklift-forks.mesh` —, `forkOffset` carriage position in chassis +space (purely visual — the fork attach box below is absolute chassis +space and the offset cancels out of the carry pin math), +`forkMaxHeight`, `forkSpeed`; the driver raises/lowers with R/F, +`inputFork` -1..1, runtime `forkHeight`), and a +`wheels` list — per wheel `position` (chassis-local), `radius`, `width`, `suspensionMinLength` / `suspensionMaxLength` / `suspensionFrequency` / `suspensionDamping`, `maxSteerAngleDeg` (0 = fixed), `driven`, `maxHandBrakeTorque`. Keep @@ -934,6 +1003,21 @@ editable via `ui/VehicleEditor`): `maxTorque`, `maxPitchRollAngleDeg`, `mEngineTorqueRatio` evenly across driven wheels (Jolt asserts the ratios sum to 1). +Fork carry mechanic (demo-sokoban Phase 8): when the fork starts rising +from the bottom and a `PushableComponent` body's center is inside the +attach box (absolute chassis space: |x| <= 0.7, y in +[-0.7, 0.3], z in [1.4, 2.1] — past the chassis front face, so the +carried crate never overlaps the collider), `VehicleSystem` pins the +crate to the carriage: gravity factor 0 + a per-frame world-space +teleport against the chassis BODY (not the lagging scene node) + +zeroed velocities (`JoltPhysicsWrapper::setVelocities`). Lowering the +fork back to 0 releases the crate; an external teleport (>5 m off the +pin target, e.g. the sokoban yard reset) breaks the carry. The demo +forklift's chassis collider (box half z 1.3 with offset z -0.3, Ogre z +coverage [-1.6, +1.0]) keeps its front face at +1.0 so it stops at the +crate face while the (colliderless) tines slide under it; the extended +rear covers the counterweight. + Gotchas found while landing this (demo-sokoban Phase 1): - `EditorPhysicsSystem::buildCompoundShape()` places the rigid-body @@ -949,6 +1033,152 @@ Gotchas found while landing this (demo-sokoban Phase 1): misbehaves. - `EditorPhysicsSystem::update()` clamps deltaTime to 0.1 s so the post-load hitch cannot trigger huge catch-up steps. +- `Physics::createBody` must set `mOverrideMassProperties = + EOverrideMassProperties::CalculateInertia` alongside + `mMassPropertiesOverride`, otherwise Jolt silently derives mass from the + shape density (1000 kg/m3) and `rigidBody.mass` does nothing (found in + demo-sokoban Phase 4: a "40 kg" crate was really 1000 kg). +- flecs treats empty structs as tags: `set`/`get_mut` assert on them + ("operation invalid for empty type"). Marker components that are + serialized/edited must carry at least one field (e.g. + `PushableComponent::enabled`) and be registered with + `m_world.component()` in `EditorApp::registerComponents`. + +### PushableComponent (F11) + +`components/Pushable.hpp` — generic marker (with an `enabled` flag) for +"a dynamic prop that activities care about"; sokoban zone detection keys +off it (Phase 6), so any crate works with any activity. Serialized as +the `pushable` scene key; editor via `ui/PushableEditor`; the entity is +expected to also have a dynamic rigid body and a box collider (the demo +crates: 40 kg, friction 0.6, no restitution — the 1500 kg forklift +pushes them without launching or tipping). + +### TargetZoneComponent (F11) + +`components/TargetZone.hpp` — generic goal-zone marker for activities: +a named axis-aligned box (centred on the entity transform) with +`zoneId` (unique per yard, e.g. `yard1.pad1`), `halfExtents`, +`uncoveredMaterialName`/`coveredMaterialName` (ZoneSystem swaps the +zone renderable's material as covered feedback; empty = no swap) and an +`enabled` flag. Serialized as the `targetZone` scene key; editor via +`ui/TargetZoneEditor`; registered in `EditorApp::registerComponents`. + +### ZoneSystem (F11 zone detection) + +`systems/ZoneSystem.{hpp,cpp}` — per-frame (Game+Playing only, called +from `EditorApp` right after `PlayerControllerSystem`) detection of +`PushableComponent` props resting inside `TargetZoneComponent` zones. +Occupancy = XZ overlap within `halfExtents`, |dy| < 1 and speed below +0.2 m/s; an occupant diff emits the `EventBus` events `zone_entered` / +`zone_left` (params: `zone_id`, `entity` name, `entity_id`), including +flushes when occupants or zones vanish, so Lua-side trackers never go +stale. Covered zones get their renderable's material swapped to +`coveredMaterialName` (back to `uncoveredMaterialName` when vacated; +skipped when the names are empty or the material is missing). Prop +positions come from the physics body (`JoltPhysicsWrapper::getPosition`, +converted to render space via `RenderOriginSystem::worldToRender`), NOT +the scene node — the node lags a frame behind a physics teleport and +produced phantom zone entries. Test API: `isZoneCovered(zoneId)` / +`getZoneOccupantCount(zoneId)`. + +The sokoban rules live in Lua: `lua-scripts/sokoban.lua` (loadable with +plain `require("sokoban")` from scene scripts — the `LuaScripts` +resource-group loader maps the module name to `sokoban.lua`). +`sokoban.new{ id, pads = { zone ids }, questName, onCompleted }` +subscribes to the zone events, tracks per-pad occupants, updates +`quest..progress` and latches `quest..completed` in the global +state store (restored at registration, so reloaded scenes do not +re-fire), and sends `sokoban_progress` / `quest_completed` events. +Coverage is read from the authoritative ZoneSystem state through +`ecs.is_zone_covered(zone_id)` (the same state that paints a pad +green), reconciled on a throttled (0.25 s) `frame` tick, so a missed +zone event edge can never stall completion with all pads visibly green; +a baseline latch keeps pads already covered at registration from +counting until they have read uncovered once (a fresh instance still +needs a fresh cover). The occupant sets remain as a fallback when the +binding is absent (pure-Lua tests). +Instances ignore zone ids outside their own `pads` list, so multiple +yards coexist. The module also drives the game HUD: a +`sokoban.` status line (": crates n/m") from +registration on and a "Quest completed: " banner on +completion (the `ecs.hud` calls are guarded, so the module loads fine +without a HUD). + +Optional yard reset (Phase 8): `sokoban.new{ ..., crates = { crate +entity names }, anchor = "", resetDistance = 100 }` +records each crate's offset from the anchor at registration (offsets +are invariant under render-origin rebases) and, while the quest is +incomplete, teleports all crates back to their slots (yaw 0, physics +bodies with zeroed velocities) when the player is beyond resetDistance +and any crate is displaced > 0.5 m; fires `sokoban_reset` and a HUD +message. Built on two generic pieces: the per-frame `frame` event with +a `dt` float param (sent by `EditorApp` in Game+Playing; Lua +subscribers throttle themselves) and the LuaEntityApi additions +`ecs.get_player_character()` (the controller's live target, unlike the +stationary `get_player_entity()` controller), `ecs.get_entity_position(id)` +(render space) and `ecs.teleport_entity(id, x, y, z [, yawDeg])` +(render space in, rigid body teleported world-space; node fallback). +A related LuaEntityApi addition, `ecs.is_zone_covered(zone_id)`, reads +authoritative zone coverage from ZoneSystem (used by the sokoban +reconcile above; nil when no zone system is wired). + +### GameHudSystem (F12 HUD overlay) + +`systems/GameHudSystem.{hpp,cpp}` — game-mode-only ImGui overlay in the +top-right corner (no window chrome) for status/banner text, driven +primarily from Lua. `setStatus(key, text)` maintains persistent lines +ordered by key ("Crate Yard: crates 2/3"); `pushMessage(text, ttl)` +shows transient golden banners that fade over their last second +(default ttl 6 s). `render()` gates on Game+Playing and a live ImGui +context (headless safe); `ImGuiRenderListener` calls +`update(deltaTime)` + `render()` next to `ActuatorSystem::render()`. +Owned by `EditorApp` (`getGameHudSystem()`). Lua bindings in +`lua/LuaHudApi.{hpp,cpp}` add `ecs.hud.set_status(key, text)` / +`ecs.hud.clear_status(key)` / `ecs.hud.push_message(text [, ttl])`, +forwarded to the system set via `setLuaHudSystem()` (safe no-ops +without one). + +### VehicleControllerSystem (F10 enter/exit + drive input) + +Game-mode-only system (`systems/VehicleControllerSystem.{hpp,cpp}`) that +makes vehicles drivable by the player: + +- **Enter**: an actuator action (e.g. `vehicle_drive` in the sokoban demo) + runs a `luaTask` node `vehicleEnter` (registered in + `lua-scripts/data2.lua`) which sends the `vehicle_enter` event with a + `vehicle=` param; the system subscribes on the `EventBus` + (`enterVehicle(entity)` is also directly callable by tests). Entering + disables the player character's physics capsule + (`CharacterSystem::disablePhysics`), pins its node to + `VehicleComponent::seatOffset` (re-pinned every frame from + `postPhysicsUpdate()`, after the physics step syncs the vehicle node — + see "Frame Update Order" 21a), parks the locomotion state + machine in idle, tags the vehicle `PlayerControlledComponent`, sets + `PlayerControllerComponent::driving` and swaps `tpsDistance`/`tpsHeight` + for the vehicle's `cameraDistance`/`cameraHeight`. The character plays + the locomotion state machine's `PlayerControllerComponent::drivingState` + state ("driving", mapped to the `sitting` animation in the demo's + animation_tree.json); `idleState` is restored on exit. Entering also + pushes a controls hint to the GameHudSystem ("W/S move, A/D steer, hold + E to exit", plus "R/F fork up/down" when the vehicle has a fork). +- **Driving**: `update()` (called right before `PlayerControllerSystem` in + Game+Playing) feeds `GameInputState` into the vehicle inputs — W/S + `inputForward`, A/D `inputRight`, Space `inputHandBrake`. +- **Exit**: holding E while driving (a cooldown of `EXIT_MIN_SEAT_TIME` + 0.75 s after seating must pass first, so the entering tap never counts, + then E must be held for `EXIT_HOLD_TIME` 0.5 s) picks the first + raycast-clear spot beside (±X) or behind/ahead (±Z) of the chassis + (horizontal clearance ray + downward ground ray; `raycastQuery` sees + NON_MOVING only, so the vehicle never blocks its own exit), moves the + character node there, re-enables physics (the CharacterSystem diff check + teleports the capsule to the node) and restores the on-foot camera + settings. No clear spot → the player stays seated and a warning is + logged. +- **Teardown**: `resetDriving()` (no raycasts) runs from + `EditorApp::destroySceneEntities` before `resetControllers()`, and the + system is destroyed before `PlayerControllerSystem`/`CharacterSystem`. + Driving state is runtime-only — it does not survive save/load. ### SceneScriptComponent & SceneScriptSystem diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index c9a749c..c9f3f5c 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -21,6 +21,7 @@ set(EDITSCENE_SOURCES systems/SceneSerializer.cpp systems/PhysicsSystem.cpp systems/VehicleSystem.cpp + systems/VehicleControllerSystem.cpp systems/BuoyancySystem.cpp systems/EditorSunSystem.cpp systems/EditorSkyboxSystem.cpp @@ -86,6 +87,12 @@ set(EDITSCENE_SOURCES systems/ActuatorSystem.cpp ui/ActuatorEditor.cpp components/ActuatorModule.cpp + ui/PushableEditor.cpp + components/PushableModule.cpp + ui/TargetZoneEditor.cpp + components/TargetZoneModule.cpp + systems/ZoneSystem.cpp + systems/GameHudSystem.cpp systems/EventBus.cpp systems/EventHandlerSystem.cpp ui/EventHandlerEditor.cpp @@ -211,6 +218,7 @@ set(EDITSCENE_SOURCES lua/LuaSaveLoadApi.cpp lua/LuaGlobalStateApi.cpp lua/LuaDoorApi.cpp + lua/LuaHudApi.cpp lua/LuaTerrainApi.cpp lua/LuaSceneSwitchApi.cpp systems/TerrainTests.cpp @@ -410,6 +418,7 @@ set(EDITSCENE_HEADERS lua/LuaSaveLoadApi.hpp lua/LuaGlobalStateApi.hpp lua/LuaDoorApi.hpp + lua/LuaHudApi.hpp lua/LuaTerrainApi.hpp lua/LuaSceneSwitchApi.hpp ) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index d38eb7f..314e89a 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -8,6 +8,8 @@ #include #include "systems/EditorUISystem.hpp" #include "systems/VehicleSystem.hpp" +#include "systems/ZoneSystem.hpp" +#include "systems/GameHudSystem.hpp" #include "systems/PhysicsSystem.hpp" #include "systems/BuoyancySystem.hpp" #include "systems/EditorSunSystem.hpp" @@ -54,9 +56,11 @@ #include "lua/LuaCharacterApi.hpp" #include "lua/LuaSaveLoadApi.hpp" #include "lua/LuaDoorApi.hpp" +#include "lua/LuaHudApi.hpp" #include "lua/LuaGlobalStateApi.hpp" #include "lua/LuaSceneSwitchApi.hpp" #include "systems/PlayerControllerSystem.hpp" +#include "systems/VehicleControllerSystem.hpp" #include "systems/SceneSerializer.hpp" #include "systems/SaveLoadSystem.hpp" #include "systems/SaveLoadDialog.hpp" @@ -104,6 +108,8 @@ #include "components/NavMesh.hpp" #include "components/SmartObject.hpp" #include "components/Actuator.hpp" +#include "components/Pushable.hpp" +#include "components/TargetZone.hpp" #include "components/GoapPlanner.hpp" #include "components/GoapRunner.hpp" #include "components/PathFollowing.hpp" @@ -184,6 +190,11 @@ void ImGuiRenderListener::preViewportUpdate( ActuatorSystem *actuatorSys = m_editorApp->getActuatorSystem(); if (actuatorSys) actuatorSys->render(); + GameHudSystem *gameHud = m_editorApp->getGameHudSystem(); + if (gameHud) { + gameHud->update(m_deltaTime); + gameHud->render(); + } } /* Scene-switch loading cover: fullscreen black overlay for the @@ -393,6 +404,8 @@ void EditorApp::destroyEditorSystems() // DialogueSystem is a singleton, no manual teardown needed m_startupMenuSystem.reset(); + /* Un-seat the character before the controllers/characters go. */ + m_vehicleControllerSystem.reset(); m_playerControllerSystem.reset(); m_itemSystem.reset(); m_eventHandlerSystem.reset(); @@ -429,6 +442,8 @@ void EditorApp::destroyEditorSystems() m_sunSystem.reset(); m_buoyancySystem.reset(); /* Vehicles hold physics step listeners; tear down before physics. */ + m_gameHudSystem.reset(); + m_zoneSystem.reset(); m_vehicleSystem.reset(); m_physicsSystem.reset(); @@ -537,6 +552,13 @@ void EditorApp::setup() m_vehicleSystem = std::make_unique( m_world, m_physicsSystem->getPhysicsWrapper()); + /* F11 target zones (needs the physics wrapper). */ + m_zoneSystem = std::make_unique( + m_world, m_physicsSystem->getPhysicsWrapper()); + + /* F12 game HUD overlay (ImGui, Lua-driven). */ + m_gameHudSystem = std::make_unique(this); + // Setup buoyancy system (requires physics system) // Get the physics wrapper from the physics system m_buoyancySystem = std::make_unique( @@ -785,6 +807,13 @@ void EditorApp::setup() std::make_unique( m_world, m_sceneMgr, this); + /* F10: vehicle enter/exit + drive input (game mode). Must be + * created after PlayerControllerSystem, CharacterSystem and + * ActuatorSystem; it talks to all three. */ + m_vehicleControllerSystem = + std::make_unique(m_world, + this); + /* Set when startup_menu.json loaded successfully; its scene * scripts run after Lua init below. */ bool startupMenuLoaded = false; @@ -883,7 +912,86 @@ void EditorApp::setup() editScene::registerLuaSceneSwitchApi(L); editScene::registerLuaGlobalStateApi(L); editScene::registerLuaDoorApi(L); + editScene::registerLuaHudApi(L); + editScene::setLuaHudSystem(m_gameHudSystem.get()); editScene::setSceneSwitchEditorApp(this); + editScene::setLuaPlayerCharacterResolver( + [this]() { return getPlayerCharacterEntity(); }); + editScene::setLuaEntityPositionGetter( + [](flecs::entity e, Ogre::Vector3 &outPos) { + if (!e.has()) + return false; + const TransformComponent &t = + e.get(); + outPos = t.node ? + t.node->_getDerivedPosition() : + t.position; + return true; + }); + editScene::setLuaEntityTeleporter( + [](flecs::entity e, const Ogre::Vector3 &pos, + bool hasYaw, float yawDeg) { + JoltPhysicsWrapper *phys = + JoltPhysicsWrapper:: + getSingletonPtr(); + if (phys && + e.has()) { + const RigidBodyComponent &rb = + e.get(); + if (rb.bodyCreated) { + Ogre::Quaternion ori = + phys->getRotation( + rb.bodyID); + if (hasYaw) + ori = Ogre::Quaternion( + Ogre::Degree( + yawDeg), + Ogre::Vector3:: + UNIT_Y); + /* Wrapper positions are + * absolute world space; the + * script side works in + * render space. */ + RenderOriginSystem *ro = + RenderOriginSystem:: + getInstance(); + if (ro) + phys->setPosition( + rb.bodyID, + ro->renderToWorld( + pos), + true); + else + phys->setPosition( + rb.bodyID, + pos, true); + phys->setRotation( + rb.bodyID, ori, + true); + phys->setVelocities( + rb.bodyID, + Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + return true; + } + } + if (!e.has()) + return false; + auto &t = e.get_mut(); + t.position = pos; + if (hasYaw) + t.rotation = Ogre::Quaternion( + Ogre::Degree(yawDeg), + Ogre::Vector3::UNIT_Y); + t.applyToNode(); + t.markChanged(); + return true; + }); + editScene::setLuaZoneCoverageChecker( + [this](const std::string &zoneId) { + return m_zoneSystem && + m_zoneSystem->isZoneCovered(zoneId); + }); // Scene scripts execute in this shared Lua state. SceneScriptSystem::init(L); @@ -1238,7 +1346,11 @@ void EditorApp::renderSceneSwitchCover() void EditorApp::destroySceneEntities() { /* Drop all controller state first so spawner locks, pivot nodes - * and PlayerControlledComponent tags don't leak. */ + * and PlayerControlledComponent tags don't leak. The vehicle + * controller goes first: it un-seats the character and restores + * the player controller fields before those are reset. */ + if (m_vehicleControllerSystem) + m_vehicleControllerSystem->resetDriving(); if (m_playerControllerSystem) m_playerControllerSystem->resetControllers(); @@ -1954,6 +2066,12 @@ void EditorApp::setupECS() // Register Actuator component m_world.component(); + // Register Pushable marker component (F11) + m_world.component(); + + // Register Target Zone component (F11) + m_world.component(); + // Register Event Handler component m_world.component(); @@ -2182,10 +2300,27 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) } else if (m_gameMode == GameMode::Game) { if (m_gamePlayState == GamePlayState::Playing) { m_playTime += fixedEvt.timeSinceLastFrame; + /* The vehicle controller runs first: it feeds this + * frame's drive inputs into the VehicleComponent. + * (The seat pin and the driving camera run after + * the physics step — see below.) */ + if (m_vehicleControllerSystem) { + m_vehicleControllerSystem->update( + fixedEvt.timeSinceLastFrame); + } if (m_playerControllerSystem) { m_playerControllerSystem->update( fixedEvt.timeSinceLastFrame); } + /* Zone detection feeds Lua-side activity rules + * (sokoban) through zone_entered/zone_left. */ + if (m_zoneSystem) + m_zoneSystem->update(); + /* Generic per-frame event for Lua activity rules + * (e.g. the sokoban yard reset); subscribers must + * throttle themselves. */ + EventBus::getInstance().send("frame", "dt", + fixedEvt.timeSinceLastFrame); } } @@ -2335,6 +2470,18 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) m_vehicleSystem->postPhysicsUpdate(); } + /* --- Driving seat pin + camera (after the physics sync, so + * the driver, the vehicle node and the camera all come from + * the same physics state; pinning pre-step made the seat + * jitter under the driver) --- */ + if (m_vehicleControllerSystem) { + m_vehicleControllerSystem->postPhysicsUpdate(); + } + if (m_playerControllerSystem) { + m_playerControllerSystem->postPhysicsUpdate( + fixedEvt.timeSinceLastFrame); + } + /* --- Hair physics pose read-back (after physics step) --- */ if (m_hairPhysicsSystem) { m_hairPhysicsSystem->postPhysicsUpdate(); @@ -2629,6 +2776,17 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) m_gameInput.i = true; m_gameInput.iPressed = true; break; + case 'r': + case 'R': + if (!isRepeat && !m_gameInput.r) { + m_gameInput.r = true; + m_gameInput.rPressed = true; + } + break; + case OgreBites::SDLK_SPACE: + m_gameInput.space = true; + m_gameInput.spacePressed = true; + break; } return true; } @@ -2702,6 +2860,13 @@ bool EditorApp::keyReleased(const OgreBites::KeyboardEvent &evt) case 'I': m_gameInput.i = false; break; + case 'r': + case 'R': + m_gameInput.r = false; + break; + case OgreBites::SDLK_SPACE: + m_gameInput.space = false; + break; } return true; diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 52e4353..7cb36cf 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -15,6 +15,8 @@ // Forward declarations class EditorUISystem; class VehicleSystem; +class ZoneSystem; +class GameHudSystem; class EditorCamera; class EditorPhysicsSystem; class EditorLightSystem; @@ -38,6 +40,7 @@ class StartupMenuSystem; class PauseMenuSystem; class DialogueSystem; class PlayerControllerSystem; +class VehicleControllerSystem; class BuoyancySystem; class EditorSunSystem; class EditorSkyboxSystem; @@ -70,9 +73,13 @@ struct GameInputState { bool e = false; bool f = false; bool i = false; + bool r = false; + bool space = false; bool ePressed = false; bool fPressed = false; bool iPressed = false; + bool rPressed = false; + bool spacePressed = false; float mouseDeltaX = 0.0f; float mouseDeltaY = 0.0f; bool mouseMoved = false; @@ -85,6 +92,8 @@ struct GameInputState { ePressed = false; fPressed = false; iPressed = false; + rPressed = false; + spacePressed = false; } void clearMovementInput() @@ -324,10 +333,31 @@ public: { return m_playerControllerSystem.get(); } + CharacterSystem *getCharacterSystem() const + { + return m_characterSystem.get(); + } + VehicleControllerSystem *getVehicleControllerSystem() const + { + return m_vehicleControllerSystem.get(); + } VehicleSystem *getVehicleSystem() const { return m_vehicleSystem.get(); } + ZoneSystem *getZoneSystem() const + { + return m_zoneSystem.get(); + } + GameHudSystem *getGameHudSystem() const + { + return m_gameHudSystem.get(); + } + /* Shared Lua state (tests/tools; scripts access it via the ecs API). */ + editScene::LuaState *getLuaState() + { + return &m_lua; + } EditorPhysicsSystem *getEditorPhysicsSystem() const { return m_physicsSystem.get(); @@ -440,6 +470,10 @@ private: std::unique_ptr m_playerControllerSystem; /* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */ std::unique_ptr m_vehicleSystem; + std::unique_ptr m_zoneSystem; + std::unique_ptr m_gameHudSystem; + /* F10 enter/exit + drive input for vehicles (game mode). */ + std::unique_ptr m_vehicleControllerSystem; // State uint16_t m_currentModifiers; diff --git a/src/features/editScene/GameFeatures202609.md b/src/features/editScene/GameFeatures202609.md index f2d9336..476dd32 100644 --- a/src/features/editScene/GameFeatures202609.md +++ b/src/features/editScene/GameFeatures202609.md @@ -1047,6 +1047,83 @@ with any material. --- +## F10 — Vehicle concept + forklift + +**Status: implemented (2026-09-14, demo-sokoban).** Test bed: +`demos/demo-sokoban` (target `demoSokoban`); headless checks +`--test-vehicle`, `--test-vehicle-drive`, `--test-fork`. + +- **Vehicle concept**: any entity with `VehicleComponent` + a dynamic + box `RigidBodyComponent` becomes drivable — `VehicleSystem` attaches a + Jolt `VehicleConstraint` (`WheeledVehicleController`, raycast wheels) + to the chassis body and feeds the component's driver input fields + (`inputForward`/`inputRight`/`inputBrake`/`inputHandBrake`/`inputFork`) + into it (`systems/VehicleSystem.{hpp,cpp}`, `components/Vehicle.hpp`). + Per-wheel config (suspension, steer, driven, handbrake), `rearSteer` + for rear-steered vehicles, `engineBrake` against coasting, optional + wheel/steering-wheel/fork visual child nodes. Enter/exit lives in + `VehicleControllerSystem` (actuator action -> `vehicle_enter` event; + character pinned to `seatOffset`, `driving` locomotion state, TPS boom + swap, hold-E exit with seat cooldown). +- **Forklift**: the demo vehicle (`assets/blender/scripts/ + gen_sokoban_assets.py`): rear-steer, driven front axle, working + steering wheel visual (`forklift-steering-wheel.mesh`, spins with the + smoothed road-wheel angle x `steeringRatio`) and a working fork + (`forklift-forks.mesh`): R/F raise/lower the carriage (0 .. + `forkMaxHeight`); a `PushableComponent` crate inside the attach box at + the tines pins to the carriage when the fork rises (gravity off, + per-frame world-space pin against the chassis body) and releases when + the fork is lowered back to 0. External teleports break the carry. + The chassis collider's front face stops at the mast (box half z 1.3 + with offset z -0.3; Ogre z [-1.6, +1.0]) so the colliderless tines + slide under crates; the extended rear covers the counterweight. + Entering a vehicle pushes a controls hint to the HUD (F12). + +## F11 — Sokoban activity (zones + Lua rules + quest registration) + +**Status: implemented (2026-09-14, demo-sokoban).** Headless checks +`--test-sokoban`, `--test-crate`, `--test-sokoban-reset`. + +- **Crate**: `PushableComponent` + dynamic rigid body (40 kg); pushed by + the forklift chassis or carried on the fork (F10). +- **Designated positions**: `TargetZoneComponent` pads (named zone ids, + half extents, covered/uncovered material swap). `ZoneSystem` reports + resting pushable props per zone via `zone_entered`/`zone_left` + (`zone_id`, `entity`, `entity_id`), physics-body positions + authoritative. +- **Rules + quest registration in Lua**: `lua-scripts/sokoban.lua`, + `sokoban.new{ id, pads, questName, ... }` latches + `quest..completed` in the F9 global store (restored at + registration) and fires `sokoban_progress` / `quest_completed` — + the yard activity registers its completion as a quest in the quest + system. Coverage is read from the authoritative zone state via + `ecs.is_zone_covered(zone_id)` and reconciled on a 0.25 s `frame` + tick (a missed zone event edge cannot stall completion with all pads + green; pads already covered at registration need one fresh cover via + a baseline latch). +- **Yard reset**: with `crates`/`anchor`/`resetDistance` in the config, + leaving an incomplete, disturbed yard (player > 100 m from the anchor, + any crate displaced) teleports all crates back to their slots and + fires `sokoban_reset`. Built on the generic per-frame `frame` event + (`dt` param) and `ecs.get_player_character()` / + `ecs.get_entity_position()` / `ecs.teleport_entity()` Lua bindings. + +## F12 — HUD status display + +**Status: implemented (2026-09-13, demo-sokoban).** Asserted headless in +`--test-sokoban` (status line text + completion banner). + +`GameHudSystem` (game-mode-only chromeless ImGui overlay, top-right): +persistent status lines via `setStatus(key, text)` and transient fading +banners via `pushMessage(text, ttl)`. Scriptable through +`ecs.hud.set_status` / `ecs.hud.clear_status` / `ecs.hud.push_message` +(`lua/LuaHudApi.{hpp,cpp}`, safe no-ops without a HUD). First client: +the sokoban rules (": crates n/m" status, +"Quest completed: " banner). A future quest system reuses +the same display. + +--- + ## Cross-cutting requirements For every feature above, per project rules (`AGENTS.md`): diff --git a/src/features/editScene/components/PlayerController.hpp b/src/features/editScene/components/PlayerController.hpp index aeee792..97edb32 100644 --- a/src/features/editScene/components/PlayerController.hpp +++ b/src/features/editScene/components/PlayerController.hpp @@ -37,6 +37,14 @@ struct PlayerControllerComponent { /* Runtime: set by ActuatorSystem while executing an action */ bool inputLocked = false; + /* Runtime: set by VehicleControllerSystem while the player is + * driving a vehicle; locomotion and actuator prompts are skipped. */ + bool driving = false; + /* Locomotion state-machine state selected by VehicleControllerSystem + * while driving (animation_tree.json "locomotion" machine, currently + * mapped to the "sitting" animation); idleState is restored on + * exit. */ + Ogre::String drivingState = "driving"; }; /** diff --git a/src/features/editScene/components/Pushable.hpp b/src/features/editScene/components/Pushable.hpp new file mode 100644 index 0000000..ed3ffd0 --- /dev/null +++ b/src/features/editScene/components/Pushable.hpp @@ -0,0 +1,20 @@ +#ifndef EDITSCENE_PUSHABLE_HPP +#define EDITSCENE_PUSHABLE_HPP +#pragma once + +/** + * Pushable component (F11, see demos/demo-sokoban/PLAN.md). + * + * Generic marker for "a dynamic prop that activities care about": zone + * detection (TargetZoneComponent) keys off it, so any crate can be used + * by any sokoban instance (or a future activity). The entity is expected + * to also carry a dynamic RigidBodyComponent and a box collider. + * + * Note: flecs treats empty structs as tags (set/get_mut assert on them), + * so this marker carries an enabled flag to stay a real component. + */ +struct PushableComponent { + bool enabled = true; +}; + +#endif // EDITSCENE_PUSHABLE_HPP diff --git a/src/features/editScene/components/PushableModule.cpp b/src/features/editScene/components/PushableModule.cpp new file mode 100644 index 0000000..be95d99 --- /dev/null +++ b/src/features/editScene/components/PushableModule.cpp @@ -0,0 +1,20 @@ +#include "Pushable.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/PushableEditor.hpp" + +REGISTER_COMPONENT_GROUP("Pushable", "Game", PushableComponent, + PushableEditor) +{ + registry.registerComponent( + "Pushable", "Game", std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/components/TargetZone.hpp b/src/features/editScene/components/TargetZone.hpp new file mode 100644 index 0000000..a56459b --- /dev/null +++ b/src/features/editScene/components/TargetZone.hpp @@ -0,0 +1,34 @@ +#ifndef EDITSCENE_TARGET_ZONE_HPP +#define EDITSCENE_TARGET_ZONE_HPP +#pragma once + +#include + +/** + * Target zone component (F11, see demos/demo-sokoban/PLAN.md). + * + * Generic "pad/zone" marker: an axis-aligned box (centred on the + * entity transform) that activities use as a goal area. ZoneSystem + * (Phase 6) tests PushableComponent entities for XZ-overlap + nearly- + * at-rest against every zone and emits zone_entered/zone_left events; + * it also swaps the zone entity's material between + * uncovered/coveredMaterialName as feedback. + * + * Generic on purpose: any activity can use zones, not just sokoban. + * Zone ids must be unique per yard (e.g. "yard1.pad1") so multiple + * instances never interfere. + */ +struct TargetZoneComponent { + /* Unique zone id, e.g. "yard1.pad1". */ + Ogre::String zoneId; + /* Zone box half extents in entity space (y is only informational + * for the XZ-overlap check). */ + Ogre::Vector3 halfExtents = Ogre::Vector3(0.6f, 0.1f, 0.6f); + /* Material swapped onto the zone entity's renderable by ZoneSystem + * when the zone is uncovered/covered; empty = no visual swap. */ + Ogre::String uncoveredMaterialName; + Ogre::String coveredMaterialName; + bool enabled = true; +}; + +#endif // EDITSCENE_TARGET_ZONE_HPP diff --git a/src/features/editScene/components/TargetZoneModule.cpp b/src/features/editScene/components/TargetZoneModule.cpp new file mode 100644 index 0000000..edc4642 --- /dev/null +++ b/src/features/editScene/components/TargetZoneModule.cpp @@ -0,0 +1,20 @@ +#include "TargetZone.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/TargetZoneEditor.hpp" + +REGISTER_COMPONENT_GROUP("TargetZone", "Game", TargetZoneComponent, + TargetZoneEditor) +{ + registry.registerComponent( + "TargetZone", "Game", std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/components/Vehicle.hpp b/src/features/editScene/components/Vehicle.hpp index f00c766..57e1eae 100644 --- a/src/features/editScene/components/Vehicle.hpp +++ b/src/features/editScene/components/Vehicle.hpp @@ -52,21 +52,71 @@ struct VehicleComponent { /* Degrees; 0 = unlimited. */ float maxPitchRollAngleDeg = 60.0f; - /* Driver seat offset in chassis space (used by the future - * enter/exit flow to seat the player character). */ + /* Driver seat offset in chassis space (used by the + * VehicleControllerSystem enter/exit flow to seat the player + * character). */ Ogre::Vector3 seatOffset = Ogre::Vector3(0.0f, 1.0f, 0.0f); + /* Rear-wheel steering (like a real forklift): flips the steer sign + * so D still turns the nose right — with the steered axle behind + * the driver the raw wheel angle would turn the nose left. */ + bool rearSteer = false; + + /* TPS camera boom while driving: VehicleControllerSystem swaps the + * player controller's tpsDistance/tpsHeight for these on enter and + * restores them on exit. */ + float cameraDistance = 5.0f; + float cameraHeight = 2.2f; + + /* Engine braking (0 = off): when the driver gives no throttle/ + * brake input and the vehicle is still rolling above ~0.5 m/s, + * this brake value (0..1) is applied so a heavy chassis does not + * coast forever. */ + float engineBrake = 0.5f; + /* Wheel visual mesh (Y-axis aligned cylinder); empty = no wheel * visuals. */ Ogre::String wheelMeshName; - /* Driver input, written by the controlling system (the future - * VehicleControllerSystem or tests), consumed by VehicleSystem: - * forward/right -1..1, brake/handbrake 0..1. */ + /* Steering wheel visual: mesh modelled around the origin with its + * spin axis on local Y (same convention as the wheel mesh). + * VehicleSystem places it at steeringWheelOffset (chassis space), + * tilts it steeringWheelTiltDeg back from vertical toward the + * driver and spins it around its local Y with the smoothed + * steering angle times steeringRatio. Empty mesh name = no + * steering wheel visual. */ + Ogre::String steeringWheelMeshName; + Ogre::Vector3 steeringWheelOffset = Ogre::Vector3::ZERO; + float steeringWheelTiltDeg = 25.0f; + float steeringRatio = 4.0f; + + /* Fork lift (forklift): a child node with forkMeshName is created + * like the wheel visuals and sits at forkOffset (chassis space) + * when the fork is down; inputFork raises/lowers it between 0 and + * forkMaxHeight at forkSpeed. + * + * Carry mechanic: when the fork starts rising from the bottom and + * a PushableComponent body's center is inside the attach box at + * the tines (see VehicleSystem), the crate is pinned to the + * carriage and follows the fork (gravity off, teleported with the + * chassis each frame); when the fork is lowered back to 0 the + * crate is released. Empty mesh name = no fork, no carry. */ + Ogre::String forkMeshName; + Ogre::Vector3 forkOffset = Ogre::Vector3::ZERO; + float forkMaxHeight = 1.2f; + float forkSpeed = 0.8f; + + /* Driver input, written by the controlling system + * (VehicleControllerSystem or tests), consumed by VehicleSystem: + * forward/right/fork -1..1, brake/handbrake 0..1. */ float inputForward = 0.0f; float inputRight = 0.0f; float inputBrake = 0.0f; float inputHandBrake = 0.0f; + float inputFork = 0.0f; + + /* Runtime: current fork height above forkOffset (0..forkMaxHeight). */ + float forkHeight = 0.0f; /* Runtime: the Jolt vehicle constraint on the chassis body. */ JPH::VehicleConstraint *constraint = nullptr; diff --git a/src/features/editScene/demos/demo-sokoban/PLAN.md b/src/features/editScene/demos/demo-sokoban/PLAN.md index d22e3eb..8f75717 100644 --- a/src/features/editScene/demos/demo-sokoban/PLAN.md +++ b/src/features/editScene/demos/demo-sokoban/PLAN.md @@ -1,5 +1,10 @@ # demo-sokoban — Implementation Plan +**Status: COMPLETE (2026-09-15).** All phases landed; full headless test +battery green (`--test-crate`, `--test-fork`, `--test-vehicle`, +`--test-vehicle-drive`, `--test-switch`, `--test-sokoban`, +`--test-sokoban-reset`). + Forklift sokoban activity in the open world of `demo_scene_exterior.json`: the player finds a drivable forklift near the building, drives it (Jolt `VehicleConstraint`), pushes crates onto marked target pads in a walled @@ -94,8 +99,10 @@ existing content site at world ~(4000, 10, 4000). 1. Vehicle physics: no `VehicleConstraint` use anywhere; the wrapper has no constraint support at all. -2. Vehicle concept: no `VehicleComponent`/`VehicleSystem`, no - enter/exit flow, no vehicle input/camera. +2. ~~Vehicle concept: no `VehicleComponent`/`VehicleSystem`, no + enter/exit flow, no vehicle input/camera.~~ (Phases 1-3: + `VehicleComponent`/`VehicleSystem`, `VehicleControllerSystem` + enter/exit + drive input + TPS camera swap all exist now.) 3. Quest system: none in editScene (only legacy `src/gamedata`, off limits) — deliberately out of scope; only the HUD status display + global-state record + event are built. @@ -202,78 +209,223 @@ cd build-vscode/src/features/editScene/demos/demo-sokoban ``` -### Phase 2 — Blender assets +### Phase 2 — Blender assets — DONE -- `assets/blender/vehicles/forklift.blend`: chassis, mast, fork, 4 - wheels as separately exported meshes (wheel visuals are moved by - `VehicleSystem` from constraint state). -- `assets/blender/vehicles/crate.blend` (1 m box, or reuse an existing - crate/pallet asset if one fits) and a flat target-pad marker mesh. -- Extend/reuse the vehicle export script in `assets/blender/scripts/` - (check `export_vehicles.py`) and the CMake asset rules so the meshes - land in the staged `resources/` (the demo symlinks it). -- Swap the Phase 1 placeholder boxes for the real meshes in the scene. +Status: complete; forklift/crate/pad meshes are generated, exported and +live in both demo scenes (`forklift1` uses `forklift.mesh` + +`forklift-wheel.mesh`); all headless tests still pass and the visuals +were screenshot-verified. -Verify: run the demo with `--force-pos` + `--screenshot` (see the base -demo's debug capture flags) and inspect the render. +What landed: -### Phase 3 — Enter/exit and vehicle controller (F10) +- `assets/blender/scripts/gen_sokoban_assets.py` — one-shot generator + (run with `blender -b -Y -P ...`) that builds the geometry and writes + `assets/blender/vehicles/{forklift,crate,pad}.blend`: + - `forklift.blend`: `forklift` (joined body, counterweight, seat, + overhead guard, mast, fork tines; front = Blender -Y = Ogre +Z) and + `forklift-wheel` (single wheel, axle along X = the Jolt wheel spin + axis, reused for all 4 wheel visuals via + `VehicleComponent::wheelMeshName`); + - `crate.blend`: `crate` — 1 m box (0.96 core + proud edge beams) + centred at the origin, materials `crate` / `crate-frame`; + - `pad.blend`: `pad` — flat 1.2 x 1.2 x 0.04 marker slab, single + material `pad` (the zone system will swap it for covered/uncovered + feedback in Phase 6). +- `assets/blender/vehicles/CMakeLists.txt`: the three .blend files are + in `VEHICLES_SRC`, so `import_vehicles` exports + `{forklift,crate,pad}.{glb,scene}` plus per-object `.mesh` files + (`forklift.mesh`, `forklift-wheel.mesh`, `crate.mesh`, `pad.mesh`) + and materials into `resources/vehicles/` via the existing + `export_vehicles.py` pipeline (blender2ogre). Gotcha: the exporter + names `.mesh` files after the mesh *data* name, not the object name — + the generator sets both. +- Scene swap: `forklift1` renderable `Cube.mesh` (scaled) -> + `forklift.mesh` (scale 1) and `wheelMeshName` -> + `forklift-wheel.mesh` in `demo_scene_exterior.json` and + `demo_scene_vehicletest.json`. +- Resource staging note: `editSceneEditor`'s POST_BUILD copies + `${CMAKE_BINARY_DIR}/resources` into the editScene binary dir (the + demo symlinks it from there), so after building `import_vehicles` + either relink `editSceneEditor` or copy + `build*/resources/vehicles` into `build*/src/features/editScene/resources/` + manually. -- `systems/VehicleControllerSystem` (or a mode of - `PlayerControllerSystem` — decide during implementation): while - driving, reads `GameInputState` (W/S throttle, A/D steer, Space - handbrake) and feeds the vehicle; TPS camera boom follows the - forklift (same collision-clamped boom as the character camera). -- Enter: forklift entity carries an `ActuatorComponent` ("Drive" - action); the action's behavior tree runs a `luaTask` that sends - `vehicle_enter` (no new BT node types). The controller subscribes on - the `EventBus`: on enter, the player character is seated - (physics capsule disabled, node attached at the seat offset, mesh - optionally hidden) and the vehicle gets the `PlayerControlledComponent` - tag; on exit (E while driving) the character is restored beside the - forklift. -- Guardrails: cannot exit into a wall (raycast for a free spot), pause - menu and save/load keep working while driving. +Verified: +```bash +cmake --build build-vscode --target import_vehicles -j4 +# (forklift/crate/pad meshes land in build-vscode/resources/vehicles) +./demoSokoban --headless --test-vehicle # PASS +./demoSokoban --headless --test-vehicle demo_scene_vehicletest.json # PASS +./demoSokoban --headless --test-switch # PASS +# visuals: xvfb-run -a ./demoSokoban demo_scene_exterior.json \ +# --force-pos 4014 10.5 3968 0 10 --screenshot 250 /tmp/shot.png +``` -Verify: `--test-vehicle-drive` headless: emit enter, drive, exit, -assert character ends up next to the moved forklift. -### Phase 4 — Crate (F11) +### Phase 3 — Enter/exit and vehicle controller (F10) — DONE -- `components/Pushable.hpp` + module + serialization: a small generic - marker component (`PushableComponent`) for "a dynamic prop that - activities care about" — zone detection keys off it, so any crate can - be used by any sokoban instance (or a future activity). +Implemented as a separate `systems/VehicleControllerSystem.{hpp,cpp}` +(game mode only): + +- Enter: the forklift entity carries an `ActuatorComponent` + (`actionNames: ["vehicle_drive"]`); the scene `actionDatabase` action + `vehicle_drive` runs a single `luaTask` node `vehicleEnter` + (registered in `src/features/editScene/lua-scripts/data2.lua`) which + sends the `vehicle_enter` event with `vehicle=`. + `VehicleControllerSystem` subscribes on the `EventBus` (and also + exposes `enterVehicle(entity)` for tests): on enter, the player + character's physics capsule is disabled + (`CharacterSystem::disablePhysics`), its node is pinned to the + vehicle's `seatOffset` every frame (position + orientation), the + locomotion state machine is parked in the idle state, the vehicle + gets the `PlayerControlledComponent` tag, and the controller's + `tpsDistance`/`tpsHeight` are swapped for the new serialized + `VehicleComponent::cameraDistance` (5.0) / `cameraHeight` (2.2), + restored on exit. The existing collision-clamped TPS boom follows + the vehicle automatically because it tracks the character node. +- Driving: `VehicleControllerSystem::update` (runs right before + `PlayerControllerSystem::update` in Game+Playing) feeds + `GameInputState` into the vehicle inputs — W/S `inputForward`, + A/D `inputRight`, Space `inputHandBrake` (new `space`/`spacePressed` + fields on `GameInputState`, wired in the game-mode + keyPressed/keyReleased). New runtime flag + `PlayerControllerComponent::driving` gates off + `PlayerControllerSystem::updateLocomotion` and the + `ActuatorSystem` prompt collection while seated. +- Exit: E while driving picks the first raycast-clear spot beside + (±X) or behind/ahead (±Z) of the chassis (horizontal clearance ray + plus a downward ground ray; `raycastQuery` sees NON_MOVING only, so + the vehicle never blocks its own exit), moves the character node + there, re-enables its physics (the CharacterSystem diff check + teleports the capsule to the node) and restores on-foot controls. + No clear spot → stay seated, warning logged. +- Guardrails: `resetDriving()` (no raycasts) is called from + `EditorApp::destroySceneEntities` before controller reset, and the + system is torn down before `PlayerControllerSystem`/`CharacterSystem`. + Driving state is runtime-only: it does NOT survive save/load — after + a load the character stands where the seat was, on foot. + +Verified: `--test-vehicle-drive` headless — waits for forklift + +player, sends `vehicle_enter`, asserts the seated state (driving flag, +vehicle tag, capsule disabled when present), drives 240 frames with W +held, asserts ≥2 units moved, exits and asserts the character ends up +next to the forklift with physics re-enabled. `--test-vehicle`, +`--test-switch` and `--exit-after-first-frame` still PASS. + +Amendments after first hands-on drive (user feedback): + +- Exit is now hold-to-exit: a cooldown (`EXIT_MIN_SEAT_TIME` 0.75 s) + after seating must pass before E counts at all (the entering tap never + leaks through), then E must be held for `EXIT_HOLD_TIME` (0.5 s). +- New `driving` state in the `locomotion` state machine of every + male/female animation tree (mapped to the `sitting` animation for + now), selected on enter; `idleState` is restored on exit. The state + name lives in the new serialized + `PlayerControllerComponent::drivingState` ("driving"). +- `animation_tree.json` is now demo-owned: copied into + `demos/demo-sokoban/` (source tree, versioned with the demo) and + staged by copy (not symlink — the registry auto-saves on runtime + migrations and must not write into the source tree). All 7 male1* + trees carry the new state; the nav-test* trees were left alone. +- Forklift `cameraDistance`/`cameraHeight` tuned to 5.0/1.7 in the + scene JSONs (the standing idle posed the camera too high). + +Amendments after second hands-on drive (user feedback): + +- Steering felt reversed: the forklift steers with its rear axle, so + the raw wheel angle turns the nose the opposite way a front-steer car + would. New serialized `VehicleComponent::rearSteer` flag flips the + steer sign in `VehicleSystem::prePhysicsUpdate` (D still turns the + nose right = player muscle memory); set on both forklifts. Editor + checkbox in `VehicleEditor`. +- Seat position: the character sat on the roof — `seatOffset` lowered + to (0, 0.05, -0.35) (seat cushion level, cabin centre) and + `cameraHeight` to 1.2; screenshot-verified seated pose via the new + `--debug-enter-vehicle ` demo flag (sends `vehicle_enter` once + player + vehicle are up; combine with `--screenshot`). + +Amendments after third hands-on drive (user feedback): + +- Coasting damping: the forklift kept rolling far too long. New + serialized `VehicleComponent::engineBrake` (default 0.5, editor + DragFloat in `VehicleEditor`): with no throttle input and speed above + 0.5 m/s, `VehicleSystem::prePhysicsUpdate` applies this brake factor + automatically, so releasing W/S now slows the forklift like engine + braking would. +- Seat still too high (head clipped the roof): `seatOffset` y lowered + another 0.1 to -0.05 in both scene JSONs; screenshot-verified. + +### Phase 4 — Crate (F11) — DONE + +- `components/Pushable.hpp` + `PushableModule.cpp` + `ui/PushableEditor` + + serialization (`pushable` scene key): a small generic marker + component (`PushableComponent`) for "a dynamic prop that activities + care about" — zone detection keys off it, so any crate can be used by + any sokoban instance (or a future activity). It carries an `enabled` + flag because flecs treats empty structs as tags (`set`/`get_mut` + assert on them); registered with the world in + `EditorApp::registerComponents` next to `ActuatorComponent`. - Scene JSON per crate: `renderable` (crate mesh), `rigidBody` - (dynamic, tuned mass/friction, no restitution), box `collider`, - `pushable`. No other new code expected. -- Tune so the forklift pushes crates without launching or tipping them - (friction, mass ratio, chassis/fork contact height). + (dynamic, 40 kg, friction 0.6, no restitution), 0.5 half-extents box + `collider`, `pushable`. Three crates (crate1-3) parked behind the + forklift in the exterior scene, one (crate1) off the drive path in + `demo_scene_vehicletest.json`. +- Bug found while landing this: `Physics::createBody` (editScene + physics wrapper) set `mMassPropertiesOverride` but never + `mOverrideMassProperties`, so Jolt silently derived mass from shape + density (1000 kg/m3) and `rigidBody.mass` did nothing — the "40 kg" + crate was actually 1000 kg. Fixed with + `EOverrideMassProperties::CalculateInertia`; all dynamic bodies with + an explicit mass now really have it (the forklift is now truly + 1500 kg; all vehicle tests still PASS). -Verify: headless test applies an impulse through the wrapper and -asserts the crate slides and settles. +Verify: `--test-crate` headless (flat-floor scene): the settled crate +is woken and kicked with a 200 N*s impulse (5 m/s at 40 kg), slides +~1.8 units, stays upright and settles on the floor. PASS. -### Phase 5 — Sokoban yard layout + target zones (F11) +### Phase 5 — Sokoban yard layout + target zones (F11) — DONE -- Flat driving yard near the building at ~(4000, 10, 4000). Options - to evaluate, in order of preference: (a) terrain compliance - flattening (the "proper" open-world way, see - `TerrainPrefabSpawnerSystem` compliance), (b) a large flat static - "concrete pad" platform entity. Decide with a quick experiment. -- Walled rectangular yard (static boxes / low fence meshes), a - grid-aligned layout of N crates and N target pads (start: 3x3, one - solvable classic layout), the forklift parked just outside. -- `components/TargetZone.hpp` + module + serialization: generic - `TargetZoneComponent` — a named pad/zone entity (zone id, radius / - half extents, visual feedback mesh/material). Generic on purpose: - any activity can use zones, not just sokoban. -- Per yard one controller entity with a scene script that registers - the instance in Lua (see Phase 6); nothing about crates/pads/zones is - hardcoded per demo. +Status: complete; the yard is laid out in `demo_scene_exterior.json`, +screenshot-verified, and all headless tests still pass. -Verify: screenshot of the yard from `--force-pos` above the site. +What landed: -### Phase 6 — Zone detection (C++) + sokoban rules (Lua) (F11/F12) +- Yard ground decision (open question #1): resolved as "near-flat + terrain, no flattening, no pad platform". The new demo flag + `--probe-heights cx cz half step` prints a grid of streaming-terrain + heights; the yard rect (x 4005..4019, z 3936..3950) varies only + 9.91..10.05 with a smooth ~0.7% grade and no bumps, so per-entity Ys + were taken from the probe instead of running terrain compliance. +- `components/TargetZone.hpp` + `TargetZoneModule.cpp` + + `ui/TargetZoneEditor` + serialization (`targetZone` scene key at both + deserialize dispatch sites): generic `TargetZoneComponent` (zoneId, + halfExtents, uncovered/coveredMaterialName for the Phase 6 visual + swap — left empty until the materials exist, `enabled`). +- Yard layout (entity ids 504-512): three pads (`pad.mesh`, zoneIds + `yard1.pad1..3`) in a row at z=3941 (x 4008/4012/4016), crate1-3 + moved to the start row z=3945 (x 4009/4012/4015), and five boundary + walls with a 3 m gate on the south side (x 4010.5..4013.5) for the + forklift. Walls are static rigid bodies with box colliders. +- New `wall.mesh` (unit 1x1x1 cube, `sokoban-wall` material) added to + `gen_sokoban_assets.py` (`gen_wall()`) and `VEHICLES_SRC` — the + buildings `Cube.mesh` is NOT 1-unit (first attempt produced + screen-filling walls). Remember to copy + `build*/resources/vehicles/{wall.mesh,sokoban-wall.material}` into + the editScene resources dir (or relink `editSceneEditor`) after + building `import_vehicles`. +- `yard1` controller entity (id 512) with a stub inline sceneScript — + the real `sokoban.new{...}` registration lands in Phase 6. + +Verify: screenshot of the yard from above the site: +`xvfb-run -a ./demoSokoban demo_scene_exterior.json --force-pos 4017 11.2 3957 20 -30 --screenshot 250 /tmp/yard.png` +(negative pitch looks down). + +### Phase 6 — Zone detection (C++) + sokoban rules (Lua) (F11/F12) — DONE + +Status: complete; `--test-sokoban` passes and the full test battery +(`--test-crate`, `--test-vehicle`, `--test-vehicle-drive`, +`--test-switch`, first-frame smoke) is green. The as-built details +below supersede the original sketch where they differ. C++ side (per-frame detection, engine-level, multi-instance): @@ -283,52 +435,334 @@ C++ side (per-frame detection, engine-level, multi-instance): (params: zone id, entity name/id); drives the pad visual feedback (covered/uncovered material swap). Zone ids are unique per yard (`.pad`), so multiple yards never interfere. + - As built: runs in Game+Playing mode only, called from `EditorApp` + right after `PlayerControllerSystem`. Occupancy = XZ overlap within + `halfExtents`, |dy| < 1, speed < 0.2 m/s. Occupant diff handles + entity/zone destruction (vanished occupants and vanished zones flush + `zone_left` so Lua never goes stale). Test API: `isZoneCovered`, + `getZoneOccupantCount`. + - **Physics positions are authoritative**: prop positions come from + `JoltPhysicsWrapper::getPosition(bodyID)` (converted to render space + via `RenderOriginSystem::worldToRender`), NOT the scene node — the + node lags one frame behind a physics teleport and produced phantom + zone entries in the test (a crate teleported 4 m off a pad briefly + "re-entered" it, latching a bogus completion). + - Known cosmetic quirk: crates creep on the slight terrain grade, so a + crate resting on a pad can momentarily exceed the 0.2 m/s speed + threshold and fire `zone_left`/`zone_entered` pairs while sliding + (visible in the test trace). Completion is latched so this does not + affect the quest; if it ever matters, add friction to the pads or + hysteresis to the speed threshold. Lua side (the actual sokoban rules, shared by all demos): -- `lua-scripts/sokoban.lua` (staged through the `LuaScripts` resource - group like the existing scripts): `sokoban.new{ id = "yard1", pads = - { ... }, questName = "Crate Yard", onCompleted = optional fn }` - returns an instance handle. The instance subscribes to - `zone_entered`/`zone_left`, tracks crates-on-pads n/m, and on full - coverage: - - `ecs.global_state.set("quest..completed", true)` and progress - keys (survive scene switches, F9); - - `ecs.send_event("quest_completed", { quest_id = id, ... })`; - - HUD updates through the new `ecs.hud.*` bindings (Phase 7); - - `sokoban_reset`-style event re-places crates at their scene-load - transforms (positions captured at registration; also exposed as an - actuator action on a yard sign). -- The yard controller entity's inline scene script is one line: - `local game = dofile-ish require("sokoban").new{ ... }` (exact module - loading mechanism per the existing `LuaScripts` setup — check how - `scriptPath` scripts are resolved before choosing `require` vs an - explicit loader). -- Decision to make: crate positions are *not* persisted across scene - switches/saves (layout resets) unless we decide otherwise. +- `lua-scripts/sokoban.lua` (in `src/features/editScene/lua-scripts/`, + staged into the `LuaScripts` resource group by `lua_scripts_package`, + loaded with plain `require("sokoban")` — `LuaState::luaLibraryLoader` + maps dots to slashes and appends `.lua`; open question #7 resolved): + `sokoban.new{ id = "yard1", pads = { ... }, questName = "Crate Yard", + onCompleted = optional fn }` returns an instance handle. The instance + subscribes to `zone_entered`/`zone_left` via `ecs.subscribe_event`, + keeps a per-pad occupant-name set, and on full coverage (latched): + - `ecs.global.set("quest..completed", true)` and + `quest..progress` (int, updated on every change; survives scene + switches, F9) — note the API is `ecs.global`, not + `ecs.global_state` as sketched; + - `ecs.send_event("quest_completed", { quest_id = id, quest_name = + name })` and `sokoban_progress` on every progress change; + - completion is restored from global state at registration, so a + reloaded scene does not re-fire the quest. + - Instances ignore zone ids outside their `pads` list (multi-yard + isolation, verified by the test's second instance). + - The HUD binding (`ecs.hud.*`) and the reset event/actuator are + deferred to Phase 7+; they were part of the original sketch. +- The yard controller entity (id 512) inline sceneScript does + `require("sokoban")` + `sokoban.new{ id = "yard1", pads = { + "yard1.pad1", "yard1.pad2", "yard1.pad3" }, questName = "Crate Yard" }`. + The three pads now name `pad`/`pad-covered` materials; `pad-covered` + (green) is created programmatically in `demo_main.cpp` + (`createDemoResources`, demo-only). +- Decision (kept): crate positions are *not* persisted across scene + switches/saves — the layout resets to the scene JSON. -Verify: `--test-sokoban` headless: teleport crates onto pads through -the wrapper, assert the zone events fire, the Lua instance completes, -global state is set and `quest_completed` is received; exit non-zero on -failure. `--test-switch` must keep passing. A second yard instance in -the test scene verifies multi-instance isolation. +`--test-sokoban` (headless, defaults to the exterior scene): phase 0 +waits for crates/bodies/pads/ZoneSystem; phase 1 teleports crate1-3 onto +the pad centers (+0.55 y, velocity zeroed); phase 2 waits for all three +`zone_entered` events, `quest.yard1.completed` in GlobalStateStore and +the `quest_completed` event; phase 3 registers a second Lua instance +(`yardtest`, shares pad1) via `luaL_dostring` on +`EditorApp::getLuaState()`, pulls crate1 4 m west (pinned there every +frame so the slope cannot roll it back onto a pad), asserts yard1 stays +latched and yardtest does not complete without a crate, then returns +crate1 to pad1; phase 33 waits for yardtest's independent completion. +A one-shot `registeredTestyard` flag guards the registration — the +original `frame == phaseStart` trick never fired because phase +transitions set `phaseStart = frame` on the transition frame itself. +Exit code mirrors the crate test (`phase != 4` = incomplete FAIL); +judge by `[test] PASS` in stdout (known teardown segfault can mask the +exit code with 139). -### Phase 7 — HUD status display + Lua bindings (F12) +Also landed alongside this phase (user feedback on driving): see the +"Amendments after third hands-on drive" block under Phase 3 +(`VehicleComponent::engineBrake` coasting brake, seatOffset y -0.05). + +### Phase 7 — HUD status display + Lua bindings (F12) — DONE + +Status: complete; `--test-sokoban` asserts the HUD state headless and +the overlay is screenshot-verified (status lines + completion banner). + +What landed (as planned, no deviations): - `systems/GameHudSystem.{hpp,cpp}`: game-mode-only ImGui overlay, - top-right corner, no window chrome (like the actuator prompts); - reusable API: `setStatus(key, text)` for persistent lines ("Sokoban: - crates 2/3") and `pushMessage(text, ttl)` for transient banners - ("Quest completed: Crate Yard"). A future quest system reuses the - same display. + top-right corner, no window chrome (`##game_hud`, NoTitleBar/NoMove/ + NoSavedSettings...); reusable API: `setStatus(key, text)` for + persistent lines ("Crate Yard: crates 2/3", ordered by key) and + `pushMessage(text, ttl)` for transient banners ("Quest completed: + Crate Yard", golden text fading over the last second, default ttl + 6 s). render() gates on Game+Playing and on a live ImGui context + (headless safe); update(deltaTime) expires messages. Owned by + `EditorApp` (`getGameHudSystem()`), wired into + `ImGuiRenderListener::preViewportUpdate` next to + `ActuatorSystem::render()`. A future quest system reuses the same + display. - `lua/LuaHudApi.{hpp,cpp}`: `ecs.hud.set_status(key, text)`, `ecs.hud.clear_status(key)`, `ecs.hud.push_message(text [, ttl])` — - the Lua sokoban module is the first client. -- Wire into `EditorApp` next to `ActuatorSystem::render`. + forwarding to the GameHudSystem set via `setLuaHudSystem()` (safe + no-ops without one, e.g. unit tests). Registered in `EditorApp` + right after `registerLuaDoorApi`. +- `sokoban.lua` is the first client: status key `sokoban.` shows + ": crates n/m" from registration on (0/m until zones + report), updates on every zone event, and pushes the + "Quest completed: " banner (8 s) on completion. The + `ecs.hud` calls are guarded (`if ecs.hud`) so the module still loads + in bare Lua states. +- `--test-sokoban` phase 2 now also asserts the HUD status line text + and that a completion banner message exists. -Verify: screenshot showing the status line and the completion banner. +Verify: `xvfb-run -a ./demoSokoban demo_scene_exterior.json +--screenshot 200 /tmp/hud.png` (initial "crates 0/3" line); +`xvfb-run -a ./demoSokoban --test-sokoban --screenshot 115 +/tmp/hud2.png` (3/3 + "Test Yard" line + "Quest completed: Crate Yard" +banner — under xvfb the wall-clock ttl expires fast, screenshot early). -### Phase 8 — Docs and cleanup +Amendments after fourth hands-on drive (user feedback, steering wheel): + +- New steering wheel visual: `forklift-steering-wheel.mesh` (torus rim + + hub + 3 spokes in `gen_sokoban_assets.py`, spin axis Blender Z = + Ogre Y, modelled at the origin) plus a steering column added to the + forklift body mesh. New serialized `VehicleComponent` fields: + `steeringWheelMeshName`, `steeringWheelOffset` (chassis space), + `steeringWheelTiltDeg` (25, wheel plane leaning toward the driver), + `steeringRatio` (4 = steering-wheel degrees per road-wheel degree) — + all editable in `VehicleEditor`. `VehicleSystem` creates the visual + node in `createWheelVisuals`, smooths the road-wheel steer angle + (`m_steerSmooth`, ~8/s blend in prePhysicsUpdate) and spins the node + around its tilted local Y in `postPhysicsUpdate`; D spins it the way + a front-steer car would (sign follows the driver, not the raw + rear-steer wheel angle). `--test-vehicle-drive` gained a D-hold + phase asserting the node orientation actually changes + (`getSteeringWheelNode()` test accessor). +- Footwell (leg room): the one-piece body box in + `gen_sokoban_assets.py` is now split into a front hood block ending + at blender y 0.0, an under-seat block and a footwell floor plate, so + the seated driver's legs dangle into a recess instead of clipping + the body. Seat position unchanged (it was correct). +- Debug pitfall worth remembering: the steering wheel looked + "misplaced above the roof" in outside screenshots while its node + position was provably correct — screen-space misjudgement from + seeing the cab from below roof level. The decisive check was + swapping `steeringWheelMeshName` to `crate.mesh` (a 1 m box makes + the render position obvious) and projecting two known reference + points. Scene JSON is symlinked into the build dir, so such probes + need no rebuild. + +### Phase 8 — Working fork + yard reset (F10/F11) — DONE + +Status: complete; `--test-fork` (flat scene: align, lift, drive +carried, release, drive away) and `--test-sokoban-reset` (exterior +scene: displace crate, move the player 150 m away, crates teleport +back) both pass headless; seat/wheel placement screenshot-verified. + +Seat/steering-wheel placement (fifth hands-on drive): + +- `seatOffset` z -0.35 -> -0.85 in both scene JSONs: the `sitting` + animation renders the character mesh ~0.5 m forward of its node, so + the pin point must sit that far behind the visual seat (empirical, + screenshot-iterated from the side profile). +- `steeringWheelOffset` (0, 0.75, -0.3) (was (0, 1.06, 0.08) — up at + roof level). + +Amendments after sixth hands-on drive (user feedback: seat jitter, +detached steering wheel): + +- Seat jitter: the character was pinned to the seat from + `VehicleControllerSystem::update`, which runs BEFORE the physics step, + so the pin read last frame's vehicle node while the vehicle node was + synced to the current physics state right before rendering. Because + the physics step advances in fixed 60 Hz bursts (accumulator in + physics.cpp), the one-sync lag turned into visible back-forth jitter + of the seat under the (smooth, camera-glued) driver. The pin moved + to `VehicleControllerSystem::postPhysicsUpdate()`, called right after + `VehicleSystem::postPhysicsUpdate()`, and the driving-mode camera + moved to `PlayerControllerSystem::postPhysicsUpdate()` right after + that (`update()` skips the camera while `driving`), so the vehicle + node, the driver and the camera all derive from the same physics + state every rendered frame. +- Steering column: the exported `forklift.mesh` carries a baked-in + origin offset — `join_into()` in `gen_sokoban_assets.py` makes the + first part (body-front at blender (0, -0.45, 0.55)) the join target + and the exporter does not apply its object transform, so a blender + point (X, Y, Z) renders at Ogre (X, Z - 0.55, -(Y + 0.45)), NOT the + plain axis swap the old comment claimed. The column was aimed at a + phantom hub (blender (0, 0.3, 1.35)) and ended 0.6 above / 0.45 + behind the real one; it now runs from the hood top to just under the + actual hub (blender (0, -0.15, 1.30) for steeringWheelOffset + (0, 0.75, -0.3)), leaning 25 deg to match the wheel tilt. The + mapping is documented in the script's docstring. Verified by dumping + the exported mesh through OgreXMLConverter and checking the column + cluster against the hub coordinates. +- Test robustness: `--test-sokoban` phase 3 waited for a fresh + `zone_left yard1.pad1|crate1` after pulling the crate off the pad, + but a settling bounce (the zone system drops occupants above 0.2 m/s) + could consume that inside->outside edge first, stalling the test + until the phase timeout. The wait now polls + `ZoneSystem::isZoneCovered("yard1.pad1")` instead of the event edge. + +Amendments after seventh hands-on drive (user feedback: seat gap, +wheel/column angle, head vs roof): + +- Seat group (`seat-base`, `seat-back` and the under-seat `body-seat` + block in `gen_sokoban_assets.py`) moved 0.1 towards the rear + (blender +Y) while the driver pin (`seatOffset`) stays put — the + legs drop into the footwell opening and the hole behind the seat + closes. +- Wheel/column angle: the wheel's spin axis was 65 deg from vertical + while the column leans 25 deg — the wheel plane looked parallel to + the column instead of perpendicular. `steeringWheelTiltDeg` + 25 -> 65 in both scene JSONs puts the spin axis colinear with the + column (the parameter semantics — "wheel plane this many degrees + from vertical" — are unchanged). +- Overhead guard raised 0.25 (posts 0.85 -> 1.10 tall, roof at blender + z 1.92) so the seated driver's head clears the roof; the driver pin + and the chassis collider are unchanged, and the camera boom + (cameraHeight 1.2) still runs below the roof. + +Amendments after eighth hands-on drive (user feedback: driver height, +head vs roof again, detached counterweight): + +- Driver pin lowered 0.02: `seatOffset` y -0.05 -> -0.07 in both scene + JSONs (no mesh change). +- Overhead guard raised another 0.1: posts 1.10 -> 1.20 tall (centres + at blender z 1.395), roof at blender z 2.02 (spans 1.99..2.05). +- Counterweight moved forward from blender y 1.15 to 0.915 so it embeds + 1 cm into the under-seat block's back face (y 0.70) — no more gap, + and no coplanar z-fight. The chassis collider grew to cover it: + box half z 1.0 -> 1.3 with offset z -0.3 in both scene JSONs, giving + Ogre z coverage [-1.6, +1.0]; the front face stays at +1.0 so the + fork/crate interaction from Phase 8 is untouched. Mesh verified + numerically (OgreXMLConverter AABB dump): yellow z min -1.59 inside + the collider, steel y max 1.50 (raised roof), seat range unchanged. + +Amendments after ninth hands-on drive (user feedback: completion +sometimes not registering with all pads green, fork controls +undiscoverable, fork visually behind the front wheels): + +- Completion reliability: new `ecs.is_zone_covered(zone_id)` Lua + binding wired to `ZoneSystem::isZoneCovered` (the same state that + paints a pad green) via the injected-callback pattern in + LuaEntityApi. `sokoban.lua` now reads coverage from that + authoritative state (falling back to its event-tracked occupant sets + when the binding is absent, e.g. pure-Lua tests), publishes progress + only on change, and reconciles on a throttled (0.25 s) frame tick — + a missed `zone_entered`/`zone_left` edge can no longer leave every + pad visibly green without `quest_completed` firing. Registration + semantics are preserved with a baseline latch: pads already covered + when an instance registers do not count until they have read + uncovered at least once (a fresh instance on an already-covered pad + still needs a fresh cover, as `--test-sokoban` phase 3 asserts). +- Controls hint: `VehicleControllerSystem::enterVehicle` pushes a + GameHudSystem message on entry ("W/S move, A/D steer, hold E to + exit", plus "R/F fork up/down" when the vehicle has a fork). +- Fork in front of the front wheels: a `body-nose` part extends the + hood forward (Ogre z 0.45 -> 0.90, front wheels tuck under it), the + mast rails moved ahead of the front wheels (front face Ogre z 1.14 + vs wheel edge 1.15) and `mast-cross` became a low beam at axle + height linking nose to mast between the wheels. `forkOffset` z + 0 -> 1.17 in both scene JSONs shifts the whole fork assembly forward + (carriage rides the mast front, tines span Ogre z [1.125, 2.075] — + right under a crate flush against the chassis). The attach box in + `VehicleSystem::tryForkAttach` is now absolute chassis space + (constants unchanged, [1.4, 2.1]); `forkOffset` is purely visual and + cancels out of the carry pin math. +- Fork height fix: the fork mesh has its own baked origin (the + carriage location (0, -1.12, 0.15) -> Ogre (X, Z - 0.15, + -(Y + 1.12))), not the main mesh's offset, so the tines had been + rendering 0.4 too high (floating at crate mid-height between the + wheels). Tines/heels remodelled to floor level (tine top just above + the floor at chassis y -0.70). + +Working fork (fork-carry, open question 2): + +- `gen_sokoban_assets.py`: the tines + heels left `forklift.mesh` and + became `forklift-forks.mesh` (carriage plate + tines modelled at the + fork-down position, origin = chassis origin). The tines are dropped + 0.2 below the collider bottom so they hug the floor (the chassis + rides ~0.73 high on its suspension). +- Chassis collider z half extent 1.2 -> 1.0 in both scene JSONs: the + front face now stops at the crate face (faces touch at chassis z = + crate z - 1.5) instead of shoving the crate before the tines are + under it. The tines themselves have no collider and slide under the + crate. Push-sokoban still works (the front face pushes). +- New serialized `VehicleComponent` fields (editable in + `VehicleEditor`): `forkMeshName`, `forkOffset` (carriage position at + height 0, chassis space), `forkMaxHeight` (1.2), `forkSpeed` (0.8), + plus runtime `forkHeight` and driver input `inputFork` (-1..1). + Keys while driving: hold R = raise, F = lower (GameInputState gained + `r`/`rPressed`; `VehicleControllerSystem` feeds `inputFork`). +- `VehicleSystem::updateFork` (prePhysicsUpdate): integrates + `forkHeight`, moves the fork visual child node. Carry mechanic — + no Jolt constraint, a per-frame pin instead: when the fork starts + rising from the bottom and a `PushableComponent` body's center is in + the attach box (absolute chassis space: |x| <= 0.7, + y in [-0.7, 0.3], z in [1.4, 2.1] — past the chassis front face so + the carried crate never overlaps the collider), the crate's gravity + factor is zeroed and it is teleported with the chassis each frame + (world-space math against the chassis BODY, not the lagging node); + lowering the fork back to 0 releases it. An external teleport + (>5 m from the pin target, e.g. the yard reset) breaks the carry. + physics.h gained `setVelocities(id, linear, angular)`. +- Test accessors: `getForkNode(id)`, `getCarriedEntity(id)`. + +Yard reset (user request: leaving an incomplete, disturbed yard resets +the crates): + +- New generic engine pieces: a per-frame `frame` event with a `dt` + float param (EditorApp::frameRenderingQueued, game mode Playing — + Lua subscribers throttle themselves), and LuaEntityApi additions + `ecs.get_player_character()` (the controller's live target, not the + stationary controller entity), `ecs.get_entity_position(id)` + (render space) and `ecs.teleport_entity(id, x, y, z [, yawDeg])` + (teleports the rigid body world-space with zeroed velocities, node + fallback; the EditorApp-facing pieces are injected as plain + `std::function` callbacks — `setLuaPlayerCharacterResolver`, + `setLuaEntityPositionGetter`, `setLuaEntityTeleporter`, same pattern + as LuaHudApi — so LuaEntityApi.cpp keeps compiling in the standalone + `*_lua_test` targets that build against the stub `tests/Ogre.h` + without the real OGRE/Jolt headers). +- `sokoban.lua` config gains `crates` (entity names), `anchor` (static + reference entity) and `resetDistance` (default 100). At + registration each crate's offset from the anchor is recorded — + offsets are invariant under render-origin rebases. Every 0.5 s + (throttled on `frame`), while the quest is incomplete and the player + is beyond resetDistance, any crate displaced > 0.5 m from its slot + teleports ALL crates back (yaw 0) and fires `sokoban_reset` + + a HUD message. A crate on the fork is yanked free (the carry + breaks, see above). +- The yard1 controller scene script passes `crates = + { "crate1", "crate2", "crate3" }, anchor = "yard1", resetDistance = + 100`. + +### Phase 9 — Docs and cleanup — DONE - Update `src/features/editScene/AGENTS.md`: demo entry (build/run/ controls/test flags), new systems (Vehicle, VehicleController, Zone, @@ -344,9 +778,17 @@ Verify: screenshot showing the status line and the completion banner. 1. Yard ground: terrain compliance flattening vs static concrete pad platform (Phase 5 experiment decides). + RESOLVED (Phase 5): neither — the `--probe-heights` grid showed the + yard rect is near-flat (9.91..10.05, smooth ~0.7% grade), so the + yard sits on the plain terrain with per-entity Ys from the probe. 2. Forklift forks: decorative (push-only, classic sokoban) or functional lifting (adds grab/constraint mechanics — big extra; assume decorative for now). + RESOLVED (Phase 8): functional. The fork carriage/tines are a + separate mesh lifted by VehicleSystem (R/F while driving); a crate + in the attach box pins to the carriage when the fork rises and + releases at the bottom. No Jolt constraint — a per-frame world-space + pin with gravity off; external teleports break the carry. 3. Crate/pad count and layout difficulty (start with 3 and one known solvable layout). 4. Steering: rear-wheel steering like a real forklift, or front-wheel? @@ -354,9 +796,15 @@ Verify: screenshot showing the status line and the completion banner. 5. Exit placement rule when the forklift is boxed in. 6. Whether crate positions persist across scene switches/saves (default: no — the layout resets). + RESOLVED (Phase 6): no — crate positions are not persisted; the + layout resets to the scene JSON on reload. 7. Lua module loading for `lua-scripts/sokoban.lua`: how scene scripts pull in a shared module today (resource-group path + `require` shim vs an explicit loader) — decide when Phase 6 starts. + RESOLVED (Phase 6): plain `require("sokoban")` — + `LuaState::luaLibraryLoader` maps dots to slashes, appends `.lua` + and loads from the `LuaScripts` resource group; staging is the + `lua_scripts_package` ALL target. 8. `ecs.hud.*` naming and whether `GameHudSystem` messages should also be reachable from C++ only (minimal) or fully scriptable (chosen: fully scriptable, per the Lua-first rule). diff --git a/src/features/editScene/demos/demo-sokoban/animation_tree.json b/src/features/editScene/demos/demo-sokoban/animation_tree.json new file mode 100644 index 0000000..bcca2dc --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/animation_tree.json @@ -0,0 +1,1256 @@ +{ + "trees": [ + { + "name": "male1_6", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1_5", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1_2", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1_1", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1_3", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "male1_4", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "driving", + "type": "state" + }, + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + }, + { + "children": [ + { + "animationName": "sitting", + "type": "animation" + } + ], + "name": "sitting", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "skeletonSource": "", + "version": 1 + }, + { + "name": "nav-test", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walk", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "version": 1 + }, + { + "name": "nav-test_1", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + }, + { + "children": [ + { + "animationName": "treading_water", + "type": "animation" + } + ], + "name": "swim-idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "name": "swimming", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "animationName": "swimming", + "type": "animation" + } + ], + "speed": 10.0, + "type": "speed" + } + ], + "name": "swimming-fast", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "name": "locomotion", + "type": "state" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "sitting-ground", + "type": "animation" + } + ], + "name": "sitting-ground", + "type": "state" + } + ], + "name": "action", + "type": "stateMachine" + } + ], + "name": "action", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "version": 1 + }, + { + "name": "town7", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + } + ], + "name": "main", + "type": "stateMachine" + } + ], + "type": "output" + }, + "version": 1 + }, + { + "name": "town7_1", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + } + ], + "name": "idle", + "type": "stateMachine" + } + ], + "type": "output" + }, + "version": 1 + }, + { + "name": "town8a", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "animationName": "idle", + "type": "animation" + } + ], + "name": "idle", + "type": "state" + }, + { + "children": [ + { + "animationName": "walking", + "type": "animation" + } + ], + "name": "walking", + "type": "state" + }, + { + "children": [ + { + "animationName": "running", + "type": "animation" + } + ], + "name": "running", + "type": "state" + } + ], + "name": "locomotion", + "type": "stateMachine" + } + ], + "type": "output" + }, + "version": 1 + } + ], + "version": "1.0" +} diff --git a/src/features/editScene/demos/demo-sokoban/demo_main.cpp b/src/features/editScene/demos/demo-sokoban/demo_main.cpp index 6adba0a..53d97c7 100644 --- a/src/features/editScene/demos/demo-sokoban/demo_main.cpp +++ b/src/features/editScene/demos/demo-sokoban/demo_main.cpp @@ -6,16 +6,28 @@ #include "systems/CharacterRegistry.hpp" #include "systems/DoorSystem.hpp" #include "systems/PlayerControllerSystem.hpp" +#include "systems/VehicleControllerSystem.hpp" +#include "systems/VehicleSystem.hpp" +#include "systems/GameHudSystem.hpp" +#include "systems/CharacterSystem.hpp" #include "systems/TerrainSystem.hpp" #include "systems/EventBus.hpp" +#include "systems/ZoneSystem.hpp" #include "systems/GlobalStateStore.hpp" #include "components/Door.hpp" #include "components/EntityName.hpp" #include "components/Transform.hpp" #include "components/RigidBody.hpp" #include "components/Vehicle.hpp" +#include "components/Character.hpp" +#include "components/PlayerController.hpp" +#include "components/Pushable.hpp" +#include "components/TargetZone.hpp" #include "systems/PhysicsSystem.hpp" #include +#include +#include +#include #include #include #include @@ -52,6 +64,14 @@ struct ExitAfterFirstFrameListener : public Ogre::FrameListener { * --screenshot N path write frame N of the render window to * path (RenderWindow::writeContentsToFile) * and quit. + * --debug-enter-vehicle name + * send the vehicle_enter event for the named + * vehicle once the player + vehicle are up + * (visual tuning of seatOffset/camera with + * --screenshot). + * --probe-heights cx cz half step + * print a grid of streaming-terrain heights + * (world coords) around (cx, cz) and exit. * --debug-buoyancy enable BuoyancySystem debug logging (same * as the editor's --debug-buoyancy). */ struct DebugCaptureListener : public Ogre::FrameListener { @@ -63,6 +83,11 @@ struct DebugCaptureListener : public Ogre::FrameListener { float teleportPitchDeg = 0.0f; int shotFrame = -1; Ogre::String shotFile; + Ogre::String enterVehicleName; + bool enterSent = false; + bool probeEnabled = false; + double probeCx = 0.0, probeCz = 0.0; + double probeHalf = 0.0, probeStep = 1.0; int frame = 0; DebugCaptureListener(EditorApp *a) : app(a) {} @@ -70,6 +95,47 @@ struct DebugCaptureListener : public Ogre::FrameListener { bool frameRenderingQueued(const Ogre::FrameEvent &) override { frame++; + if (probeEnabled && frame > 90) { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->isActive()) { + for (double z = probeCz - probeHalf; + z <= probeCz + probeHalf + 1e-6; + z += probeStep) { + std::cout << "[probe] z=" << z << ":"; + for (double x = probeCx - probeHalf; + x <= probeCx + probeHalf + 1e-6; + x += probeStep) { + float h = ts->getHeightAt( + ts->worldToRender( + x, 0.0, z)); + std::cout << " " << h; + } + std::cout << std::endl; + } + app->getRoot()->queueEndRendering(); + } + } + if (!enterVehicleName.empty() && !enterSent && frame > 60) { + flecs::entity player = app->getPlayerCharacterEntity(); + flecs::entity vehicle = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == enterVehicleName) + vehicle = e; + }); + if (player.is_alive() && vehicle.is_alive() && + vehicle.has() && + vehicle.get() + .constraintCreated) { + std::cout << "[demo] debug-enter-vehicle: sending " + "vehicle_enter for " + << enterVehicleName << std::endl; + EventBus::getInstance().send("vehicle_enter", + "vehicle", + enterVehicleName); + enterSent = true; + } + } if (teleportEnabled) { flecs::entity player = app->getPlayerCharacterEntity(); if (player.is_alive() && @@ -175,6 +241,23 @@ static void createDemoResources() * mesh — the terrain provides the ground. */ createFloorMesh("DemoFloorPlaneInterior", "DemoFloorMaterialInterior", 0.35f, 0.5f, 0.35f); + + /* Sokoban yard: the covered feedback material for target pads + * (the uncovered one, "pad", comes from pad.mesh itself). Only + * the demo creates it; ZoneSystem skips the swap when a material + * is missing (e.g. in editSceneEditor). */ + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + if (!Ogre::MaterialManager::getSingleton().resourceExists( + "pad-covered", group)) { + Ogre::MaterialPtr mat = + Ogre::MaterialManager::getSingleton().create( + "pad-covered", group); + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + pass->setDiffuse(0.2f, 0.8f, 0.25f, 1.0f); + pass->setAmbient(0.08f, 0.32f, 0.1f); + pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f); + } } /* @@ -1041,6 +1124,1374 @@ struct VehicleTestListener : public Ogre::FrameListener { } }; +/* + * F10 end-to-end check for --test-vehicle-drive: loads the exterior + * scene, waits for the "forklift1" vehicle constraint and the player + * character, then sends the "vehicle_enter" event (the same event the + * forklift actuator's vehicle_drive action sends through the vehicleEnter + * Lua node), verifies the seated state, drives forward by holding the + * game-mode W input, exits via VehicleControllerSystem::exitVehicle() and + * verifies the character is back on foot next to the forklift. + */ +struct VehicleDriveTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + /* 0: wait spawn, 1: wait settle, 2: enter + drive, 3: W drive, + * 5: steering visual spin, 6: exit check */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + Ogre::Vector3 startPos = Ogre::Vector3::ZERO; + Ogre::Vector3 authoredPos = Ogre::Vector3::ZERO; + bool authoredValid = false; + float lastY = 0.0f; + int settleFrames = 0; + int driveStart = 0; + bool hadCapsule = false; + Ogre::Quaternion steeringRest = Ogre::Quaternion::IDENTITY; + + VehicleDriveTestListener(EditorApp *a) + : app(a) + { + } + + flecs::entity findForklift() + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == "forklift1") + result = e; + }); + return result; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 4) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 40000) { + failed = true; + failReason = "timeout in vehicle drive test"; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity forklift = findForklift(); + if (!forklift.is_alive()) { + if (frame > 600) { + failed = true; + failReason = "forklift1 entity not found"; + } + return true; + } + + /* Same grounding watchdog as VehicleTestListener: re-drop the + * chassis at its authored pose until the streaming terrain + * colliders catch it. */ + if (phase <= 1 && forklift.has() && + forklift.has()) { + const RigidBodyComponent &rb = + forklift.get(); + const TransformComponent &t = + forklift.get(); + if (!authoredValid) { + authoredPos = t.position; + authoredValid = true; + } + if (rb.bodyCreated && t.node) { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->isActive()) { + Ogre::Vector3 pos = + t.node->_getDerivedPosition(); + float ground = ts->getHeightAt(pos); + float stray = Ogre::Vector2( + pos.x - authoredPos.x, + pos.z - authoredPos.z) + .length(); + if (pos.y < ground - 0.5f || stray > 2.0f) { + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + phys->setPositionAndRotation( + rb.bodyID, + Ogre::Vector3( + authoredPos.x, + ground + 1.0f, + authoredPos.z), + Ogre::Quaternion::IDENTITY); + phys->getPhysicsSystem() + ->GetBodyInterface() + .SetLinearAndAngularVelocity( + rb.bodyID, + JPH::Vec3::sZero(), + JPH::Vec3::sZero()); + settleFrames = 0; + lastY = ground + 1.0f; + return true; + } + } + } + } + + VehicleControllerSystem *vcs = app->getVehicleControllerSystem(); + if (!vcs) { + failed = true; + failReason = "no VehicleControllerSystem"; + return true; + } + + switch (phase) { + case 0: { + /* Wait for the chassis body + vehicle constraint and + * the player character. */ + if (!forklift.has() || + !forklift.has()) + break; + const RigidBodyComponent &rb = + forklift.get(); + const VehicleComponent &v = + forklift.get(); + if (!rb.bodyCreated || !v.constraintCreated) + break; + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive()) + break; + const TransformComponent &t = + forklift.get(); + startPos = t.node ? t.node->_getDerivedPosition() : + t.position; + lastY = startPos.y; + std::cout << "[test] drive-test: forklift + player " + "ready at (" + << startPos.x << ", " << startPos.y << ", " + << startPos.z << ")" << std::endl; + phase = 1; + break; + } + case 1: { + /* Wait until the chassis rests on the terrain. */ + const TransformComponent &t = + forklift.get(); + const RigidBodyComponent &rb = + forklift.get(); + JoltPhysicsWrapper *phys = app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + float speed = + phys->getLinearVelocity(rb.bodyID).length(); + Ogre::Quaternion q = t.node ? + t.node->_getDerivedOrientation() : + t.rotation; + float upY = (q * Ogre::Vector3::UNIT_Y).y; + float y = t.node ? t.node->_getDerivedPosition().y : + t.position.y; + if (speed < 0.1f && upY > 0.95f && + fabs(y - lastY) < 0.01f) { + if (++settleFrames > 60) { + startPos = + t.node ? + t.node->_getDerivedPosition() : + t.position; + std::cout << "[test] drive-test: " + "forklift settled" + << std::endl; + phase = 2; + } + } else { + settleFrames = 0; + } + lastY = y; + break; + } + case 2: { + /* Enter through the event (the actuator action path + * ends here too) and verify the seated state. */ + EventBus::getInstance().send("vehicle_enter", + "vehicle", "forklift1"); + if (!vcs->isDriving()) { + failed = true; + failReason = "vehicle_enter event did not " + "start driving"; + break; + } + flecs::entity player = app->getPlayerCharacterEntity(); + if (!forklift.has()) { + failed = true; + failReason = "forklift1 missing " + "PlayerControlledComponent"; + break; + } + CharacterSystem *cs = app->getCharacterSystem(); + hadCapsule = cs && player.is_alive() && + cs->hasPhysicsCharacter(player); + if (hadCapsule && + !player.get() + .physicsDisabled) { + failed = true; + failReason = "character physics not " + "disabled while driving"; + break; + } + std::cout << "[test] drive-test: driving (capsule=" + << hadCapsule << ")" << std::endl; + + /* Rest orientation of the steering wheel visual (for + * the steering-spin check in phase 5). */ + VehicleSystem *vs = app->getVehicleSystem(); + Ogre::SceneNode *sw = + vs ? vs->getSteeringWheelNode(forklift.id()) : + nullptr; + if (sw) + steeringRest = sw->getOrientation(); + + /* Hold W like a player would. */ + app->getGameInputState().w = true; + driveStart = frame; + phase = 3; + break; + } + case 3: { + if (frame - driveStart < 240) + break; + app->getGameInputState().w = false; + const TransformComponent &t = + forklift.get(); + Ogre::Vector3 pos = t.node ? + t.node->_getDerivedPosition() : + t.position; + float moved = (pos - startPos).length(); + if (moved < 2.0f) { + failed = true; + failReason = + "forklift1 did not drive while " + "seated (moved " + + Ogre::StringConverter::toString( + moved) + + ")"; + break; + } + std::cout << "[test] drive-test: drove " << moved + << " units with W input" << std::endl; + + /* Hold D and verify the steering wheel visual + * spins (phase 5). */ + app->getGameInputState().d = true; + driveStart = frame; + phase = 5; + break; + } + case 5: { + if (frame - driveStart < 90) + break; + app->getGameInputState().d = false; + VehicleSystem *vs = app->getVehicleSystem(); + Ogre::SceneNode *sw = + vs ? vs->getSteeringWheelNode(forklift.id()) : + nullptr; + if (!sw) { + failed = true; + failReason = "forklift1 has no steering wheel " + "visual node"; + break; + } + float dot = fabsf(sw->getOrientation().Dot( + steeringRest)); + if (dot > 0.995f) { + failed = true; + failReason = + "steering wheel visual did not " + "spin with D input (dot " + + Ogre::StringConverter::toString(dot) + + ")"; + break; + } + std::cout << "[test] drive-test: steering wheel " + "visual spun with D input" + << std::endl; + driveStart = frame; + phase = 6; + break; + } + case 6: { + /* Let the steering settle back, then exit. */ + if (frame - driveStart < 60) + break; + const TransformComponent &ft = + forklift.get(); + Ogre::Vector3 pos = ft.node ? + ft.node->_getDerivedPosition() : + ft.position; + vcs->exitVehicle(); + if (vcs->isDriving()) { + failed = true; + failReason = "exitVehicle did not end " + "driving"; + break; + } + if (forklift.has()) { + failed = true; + failReason = "forklift1 still tagged " + "PlayerControlledComponent"; + break; + } + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive()) { + failed = true; + failReason = "player character gone after " + "exit"; + break; + } + if (hadCapsule && + player.get() + .physicsDisabled) { + failed = true; + failReason = "character physics not " + "re-enabled after exit"; + break; + } + const TransformComponent &pt = + player.get(); + Ogre::Vector3 ppos = + pt.node ? pt.node->_getDerivedPosition() : + pt.position; + float dist = (ppos - pos).length(); + if (dist > 4.0f) { + failed = true; + failReason = + "character too far from forklift " + "after exit (" + + Ogre::StringConverter::toString( + dist) + + ")"; + break; + } + std::cout << "[test] drive-test: exited, character " + << dist << " units from forklift" + << std::endl; + std::cout << "[test] PASS" << std::endl; + phase = 4; + break; + } + } + return true; + } +}; + +/* + * F11 end-to-end check for --test-crate: loads the flat-floor + * demo_scene_vehicletest.json, waits for the "crate1" pushable crate to + * settle on the floor, kicks it with a horizontal physics impulse and + * verifies it slides (PushableComponent prop behaves as a free dynamic + * body), stays upright, and settles again on the floor. + */ +struct CrateTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + /* 0: wait spawn, 1: wait settle, 2: impulse + slide, 3: re-settle */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + Ogre::Vector3 startPos = Ogre::Vector3::ZERO; + float lastY = 0.0f; + int settleFrames = 0; + int phaseStart = 0; + + CrateTestListener(EditorApp *a) + : app(a) + { + } + + flecs::entity findCrate() + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == "crate1") + result = e; + }); + return result; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 4) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 40000) { + failed = true; + failReason = "timeout in crate test"; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity crate = findCrate(); + if (!crate.is_alive()) { + if (frame > 600) { + failed = true; + failReason = "crate1 entity not found"; + } + return true; + } + if (!crate.has() || + !crate.has()) + return true; + const RigidBodyComponent &rb = crate.get(); + if (!rb.bodyCreated) + return true; + + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + const TransformComponent &t = crate.get(); + Ogre::Vector3 pos = t.node ? t.node->_getDerivedPosition() : + t.position; + Ogre::Quaternion q = t.node ? t.node->_getDerivedOrientation() : + t.rotation; + float upY = (q * Ogre::Vector3::UNIT_Y).y; + float speed = phys->getLinearVelocity(rb.bodyID).length(); + + switch (phase) { + case 0: { + if (!crate.has()) { + failed = true; + failReason = "crate1 missing PushableComponent"; + break; + } + startPos = pos; + lastY = pos.y; + phaseStart = frame; + phase = 1; + break; + } + case 1: { + /* Wait for the crate to rest on the floor. */ + if (speed < 0.05f && fabs(pos.y - lastY) < 0.005f) { + if (++settleFrames > 60) { + startPos = pos; + std::cout << "[test] crate1 settled " + "at (" + << pos.x << ", " << pos.y + << ", " << pos.z << ")" + << std::endl; + phase = 2; + } + } else { + settleFrames = 0; + } + lastY = pos.y; + if (frame - phaseStart > 3000) { + failed = true; + failReason = "crate1 never settled"; + } + break; + } + case 2: { + /* 200 N*s on 40 kg -> ~5 m/s initial slide. The body + * went to sleep while settling, so wake it first. */ + phys->activate(rb.bodyID); + phys->getPhysicsSystem()->GetBodyInterface().AddImpulse( + rb.bodyID, JPH::Vec3(200.0f, 0.0f, 0.0f)); + std::cout << "[test] crate1 impulse: active=" + << phys->isActive(rb.bodyID) << " vel=(" + << phys->getLinearVelocity(rb.bodyID).x + << ", " + << phys->getLinearVelocity(rb.bodyID).y + << ", " + << phys->getLinearVelocity(rb.bodyID).z + << ")" << std::endl; + phaseStart = frame; + phase = 3; + break; + } + case 3: { + int elapsed = frame - phaseStart; + if (elapsed == 120) { + float moved = (pos - startPos).length(); + if (moved < 1.0f) { + failed = true; + failReason = + "crate1 did not slide " + "(moved " + + Ogre::StringConverter::toString( + moved) + + ")"; + break; + } + std::cout << "[test] crate1 slid " << moved + << " units after impulse" << std::endl; + } + if (elapsed < 120) + break; + if (speed < 0.05f && fabs(pos.y - lastY) < 0.005f) { + if (++settleFrames > 60) { + if (upY < 0.95f) { + failed = true; + failReason = + "crate1 tipped over " + "(up.y " + + Ogre::StringConverter:: + toString(upY) + + ")"; + break; + } + if (pos.y < 0.1f || pos.y > 1.0f) { + failed = true; + failReason = + "crate1 off the floor " + "(y " + + Ogre::StringConverter:: + toString( + pos.y) + + ")"; + break; + } + std::cout << "[test] crate1 settled " + "again at (" + << pos.x << ", " << pos.y + << ", " << pos.z << ")" + << std::endl; + std::cout << "[test] PASS" + << std::endl; + phase = 4; + } + } else { + settleFrames = 0; + } + lastY = pos.y; + if (elapsed > 3000) { + failed = true; + failReason = "crate1 never settled after " + "the impulse"; + } + break; + } + } + return true; + } +}; + +/* + * F10 fork end-to-end check for --test-fork: loads the flat-floor + * demo_scene_vehicletest.json, teleports the forklift in front of + * "crate1" aligned with the tines, raises the fork (inputFork) and + * verifies the crate attaches and rides the fork up, stays pinned + * while driving, releases when the fork is lowered back down and does + * not follow the forklift afterwards; exits 0 on PASS. + */ +struct ForkTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + /* 0: wait bodies, 1: crate settle, 2: teleport align, + * 3: forklift re-settle, 4: raise + attach, 5: drive carried, + * 6: lower + release, 7: drive away, 8: done */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + int phaseStart = 0; + int settleFrames = 0; + float lastCrateY = 0.0f; + float crateRestY = 0.0f; + bool inputSet = false; + bool droveOff = false; + Ogre::Vector3 preDrivePos = Ogre::Vector3::ZERO; + Ogre::Vector3 dropPos = Ogre::Vector3::ZERO; + + ForkTestListener(EditorApp *a) + : app(a) + { + } + + flecs::entity findByName(const char *name) + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == name) + result = e; + }); + return result; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 8) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 40000) { + failed = true; + failReason = "timeout in fork test"; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity forklift = findByName("forklift1"); + flecs::entity crate = findByName("crate1"); + if (!forklift.is_alive() || !crate.is_alive()) { + if (frame > 600) { + failed = true; + failReason = "forklift1/crate1 entity not found"; + } + return true; + } + if (!forklift.has() || + !forklift.has() || + !crate.has()) + return true; + const RigidBodyComponent &frb = + forklift.get(); + const RigidBodyComponent &crb = crate.get(); + if (!frb.bodyCreated || !crb.bodyCreated) + return true; + auto &vc = forklift.get_mut(); + if (!vc.constraintCreated) + return true; + + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + VehicleSystem *vs = app->getVehicleSystem(); + Ogre::Vector3 cratePos = phys->getPosition(crb.bodyID); + Ogre::Vector3 forkPos = phys->getPosition(frb.bodyID); + float forkSpeedNow = + phys->getLinearVelocity(frb.bodyID).length(); + + switch (phase) { + case 0: + phase = 1; + phaseStart = frame; + lastCrateY = cratePos.y; + break; + case 1: { + /* Wait for the crate to rest on the floor. */ + float crateSpeed = + phys->getLinearVelocity(crb.bodyID).length(); + if (crateSpeed < 0.05f && + fabs(cratePos.y - lastCrateY) < 0.005f) { + if (++settleFrames > 60) { + crateRestY = cratePos.y; + std::cout << "[test] fork-test: crate " + "settled at (" + << cratePos.x << ", " + << cratePos.y << ", " + << cratePos.z << ")" + << std::endl; + phase = 2; + } + } else { + settleFrames = 0; + } + lastCrateY = cratePos.y; + if (frame - phaseStart > 3000) { + failed = true; + failReason = "crate1 never settled"; + } + break; + } + case 2: + /* Align the forklift with the crate: nose (+Z) + * toward it, front face just touching the crate + * (crate center 1.5 ahead of the chassis origin: + * collider half z 1.0 + crate half 0.5). Keep the + * settled ride height so the drop does not punt + * the crate. */ + phys->setPositionAndRotation( + frb.bodyID, + Ogre::Vector3(cratePos.x, forkPos.y, + cratePos.z - 1.55f), + Ogre::Quaternion::IDENTITY); + phys->setVelocities(frb.bodyID, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + settleFrames = 0; + phaseStart = frame; + phase = 3; + break; + case 3: + /* Wait for the forklift to settle at the aligned + * spot. */ + if (forkSpeedNow < 0.1f) { + if (++settleFrames > 60) { + inputSet = false; + phaseStart = frame; + phase = 4; + std::cout << "[test] fork-test: " + "forklift aligned" + << std::endl; + } + } else { + settleFrames = 0; + } + if (frame - phaseStart > 2000) { + failed = true; + failReason = "forklift never settled after " + "align"; + } + break; + case 4: { + /* Raise the fork; the crate must attach and ride + * up with it. */ + if (!inputSet) { + vc.inputFork = 1.0f; + inputSet = true; + } + if (vc.forkHeight >= 0.6f) { + vc.inputFork = 0.0f; + if (vs->getCarriedEntity(forklift.id()) != + crate.id()) { + failed = true; + failReason = "crate1 not attached to " + "the fork"; + break; + } + if (cratePos.y < crateRestY + 0.4f) { + failed = true; + failReason = "crate1 did not rise " + "with the fork"; + break; + } + Ogre::SceneNode *forkNode = + vs->getForkNode(forklift.id()); + if (!forkNode || + fabs(forkNode->getPosition().y - + vc.forkHeight) > 0.05f) { + failed = true; + failReason = "fork visual not " + "following forkHeight"; + break; + } + std::cout << "[test] fork-test: crate carried " + "at height " + << vc.forkHeight << std::endl; + preDrivePos = forkPos; + inputSet = false; + droveOff = false; + phaseStart = frame; + phase = 5; + } + if (frame - phaseStart > 1200) { + failed = true; + failReason = "fork never rose"; + } + break; + } + case 5: { + /* Drive forward with the carried crate, then stop with + * the handbrake (engine braking alone lets the heavy + * chassis roll for a long while). */ + int elapsed = frame - phaseStart; + if (!droveOff) { + vc.inputForward = 1.0f; + if (elapsed >= 90) { + vc.inputForward = 0.0f; + vc.inputHandBrake = 1.0f; + droveOff = true; + } + break; + } + if (elapsed > 1200) { + failed = true; + failReason = "forklift never stopped after " + "driving"; + break; + } + if (forkSpeedNow > 0.2f) + break; + vc.inputHandBrake = 0.0f; + if ((forkPos - preDrivePos).length() < 1.0f) { + failed = true; + failReason = "forklift did not drive with " + "the crate"; + break; + } + Ogre::Vector2 gap(cratePos.x - forkPos.x, + cratePos.z - forkPos.z); + if (gap.length() > 2.0f) { + failed = true; + failReason = "crate1 not carried while " + "driving"; + break; + } + std::cout << "[test] fork-test: crate carried along" + << std::endl; + inputSet = false; + phaseStart = frame; + phase = 6; + break; + } + case 6: { + /* Lower the fork; the crate must be released at + * the bottom. */ + if (!inputSet) { + vc.inputFork = -1.0f; + inputSet = true; + } + if (vc.forkHeight > 0.001f) { + if (frame - phaseStart > 1200) { + failed = true; + failReason = "fork never lowered"; + } + break; + } + vc.inputFork = 0.0f; + if (vs->getCarriedEntity(forklift.id()) != 0) { + failed = true; + failReason = "crate1 not released at fork " + "bottom"; + break; + } + dropPos = cratePos; + std::cout << "[test] fork-test: crate released" + << std::endl; + droveOff = false; + phaseStart = frame; + phase = 7; + break; + } + case 7: { + /* Drive away in reverse (the released crate sits on + * the nose); the crate must stay. */ + int elapsed = frame - phaseStart; + if (!droveOff) { + vc.inputForward = -1.0f; + if (elapsed >= 120) { + vc.inputForward = 0.0f; + vc.inputHandBrake = 1.0f; + droveOff = true; + } + break; + } + if (elapsed > 1200) { + failed = true; + failReason = "forklift never stopped after " + "driving away"; + break; + } + if (forkSpeedNow > 0.2f) + break; + vc.inputHandBrake = 0.0f; + if ((forkPos - dropPos).length() < 2.0f) { + failed = true; + failReason = "forklift did not drive away"; + break; + } + if ((cratePos - dropPos).length() > 1.0f) { + failed = true; + failReason = "crate1 followed the forklift " + "after release"; + break; + } + std::cout << "[test] PASS" << std::endl; + phase = 8; + break; + } + } + return true; + } +}; + +/* + * End-to-end check for --test-sokoban (Phase 6): runs on the exterior + * scene, teleports crate1..3 onto pad1..3 through the physics wrapper and + * asserts the whole chain: ZoneSystem zone_entered/zone_left events, the + * Lua sokoban instance (registered by the yard1 controller scene script) + * latching completion, quest.* global state and the quest_completed event. + * Then a second sokoban instance registered from the test (sharing pad1) + * verifies multi-instance isolation: yard1 stays latched while the second + * instance independently tracks and completes. + */ +struct SokobanTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + /* 0: wait for world, 1: teleport crates onto pads, 2: wait + * completion, 3: isolation (off/on pad1 with a 2nd instance), + * 33: wait 2nd completion, 4: done. */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + int phaseStart = 0; + bool registeredTestyard = false; + Ogre::Vector3 pullPos = Ogre::Vector3::ZERO; + + std::set enteredZones; /* "zone_id|entity" */ + std::set leftZones; + std::vector completedQuests; + EventBus::ListenerId subEntered = 0; + EventBus::ListenerId subLeft = 0; + EventBus::ListenerId subCompleted = 0; + + SokobanTestListener(EditorApp *a) + : app(a) + { + subEntered = EventBus::getInstance().subscribe( + "zone_entered", + [this](const Ogre::String &, + const editScene::EventParams ¶ms) { + Ogre::String key = + params.getString("zone_id") + "|" + + params.getString("entity"); + enteredZones.insert(key); + std::cout << "[test] event zone_entered " << key + << " at " << cratePosStr(params.getString("entity")) + << std::endl; + }); + subLeft = EventBus::getInstance().subscribe( + "zone_left", + [this](const Ogre::String &, + const editScene::EventParams ¶ms) { + Ogre::String key = + params.getString("zone_id") + "|" + + params.getString("entity"); + leftZones.insert(key); + std::cout << "[test] event zone_left " << key + << " at " << cratePosStr(params.getString("entity")) + << std::endl; + }); + subCompleted = EventBus::getInstance().subscribe( + "quest_completed", + [this](const Ogre::String &, + const editScene::EventParams ¶ms) { + completedQuests.push_back( + params.getString("quest_id")); + }); + } + + ~SokobanTestListener() + { + EventBus::getInstance().unsubscribe(subEntered); + EventBus::getInstance().unsubscribe(subLeft); + EventBus::getInstance().unsubscribe(subCompleted); + } + + flecs::entity findByName(const Ogre::String &name) + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == name) + result = e; + }); + return result; + } + + Ogre::String cratePosStr(const Ogre::String &crateName) + { + flecs::entity crate = findByName(crateName); + if (!crate.is_alive() || !crate.has()) + return "?"; + const RigidBodyComponent &rb = crate.get(); + if (!rb.bodyCreated) + return "?"; + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + Ogre::Vector3 p = phys->getPosition(rb.bodyID); + return Ogre::StringConverter::toString(p.x) + "," + + Ogre::StringConverter::toString(p.y) + "," + + Ogre::StringConverter::toString(p.z); + } + + Ogre::Vector3 zoneCenter(const Ogre::String &zoneId, bool &ok) + { + Ogre::Vector3 center = Ogre::Vector3::ZERO; + ok = false; + app->getWorld() + ->query() + .each([&](flecs::entity, TargetZoneComponent &z, + TransformComponent &t) { + if (z.zoneId != zoneId) + return; + center = t.node ? t.node->_getDerivedPosition() : + t.position; + ok = true; + }); + return center; + } + + void teleportCrate(const Ogre::String &crateName, + const Ogre::Vector3 &pos) + { + flecs::entity crate = findByName(crateName); + if (!crate.is_alive() || !crate.has()) + return; + const RigidBodyComponent &rb = crate.get(); + if (!rb.bodyCreated) + return; + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem()->getPhysicsWrapper(); + phys->setPosition(rb.bodyID, pos, true); + phys->getPhysicsSystem()->GetBodyInterface().SetLinearVelocity( + rb.bodyID, JPH::Vec3::sZero()); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 4) { + app->getRoot()->queueEndRendering(); + return true; + } + if (phase != 0 && frame - phaseStart > 4000) { + failed = true; + failReason = "timeout in sokoban test (phase " + + Ogre::StringConverter::toString(phase) + + ")"; + return true; + } + + switch (phase) { + case 0: { + if (frame < 90) + break; + /* All three crates must have live bodies and all + * three pads must exist. */ + bool ready = app->getZoneSystem() != nullptr; + for (const char *n : + { "crate1", "crate2", "crate3" }) { + flecs::entity c = findByName(n); + if (!c.is_alive() || + !c.has() || + !c.get() + .bodyCreated) + ready = false; + } + for (const char *z : + { "yard1.pad1", "yard1.pad2", "yard1.pad3" }) { + bool ok = false; + zoneCenter(z, ok); + if (!ok) + ready = false; + } + if (!ready) + break; + phaseStart = frame; + phase = 1; + break; + } + case 1: { + const char *crates[3] = { "crate1", "crate2", + "crate3" }; + const char *pads[3] = { "yard1.pad1", "yard1.pad2", + "yard1.pad3" }; + for (int i = 0; i < 3; i++) { + bool ok = false; + Ogre::Vector3 c = zoneCenter(pads[i], ok); + if (!ok) { + failed = true; + failReason = Ogre::String("zone ") + + pads[i] + " not found"; + break; + } + teleportCrate(crates[i], + c + Ogre::Vector3(0, 0.55f, 0)); + } + if (failed) + break; + std::cout << "[test] crates teleported onto pads" + << std::endl; + phaseStart = frame; + phase = 2; + break; + } + case 2: { + bool allCovered = true; + for (const char *z : + { "yard1.pad1", "yard1.pad2", "yard1.pad3" }) { + if (!app->getZoneSystem()->isZoneCovered(z)) + allCovered = false; + } + bool eventsSeen = true; + const char *want[3] = { "yard1.pad1|crate1", + "yard1.pad2|crate2", + "yard1.pad3|crate3" }; + for (const char *w : want) + if (!enteredZones.count(w)) + eventsSeen = false; + bool completed = GlobalStateStore::getInstance().getBool( + "quest.yard1.completed"); + bool questEvent = false; + for (const Ogre::String &q : completedQuests) + if (q == "yard1") + questEvent = true; + if (allCovered && eventsSeen && completed && + questEvent) { + /* F12: the Lua rules must have driven the + * HUD — progress status line + completion + * banner. */ + GameHudSystem *hud = app->getGameHudSystem(); + if (!hud || hud->getStatus("sokoban.yard1") != + "Crate Yard: crates 3/3") { + failed = true; + failReason = + "HUD status line missing or " + "wrong after completion"; + break; + } + if (hud->getMessageCount() == 0) { + failed = true; + failReason = "HUD completion banner " + "missing"; + break; + } + std::cout << "[test] yard1 completed: all " + "pads covered, global state set, " + "quest_completed received, HUD " + "status + banner shown" + << std::endl; + phaseStart = frame; + phase = 3; + } + break; + } + case 3: { + if (!registeredTestyard) { + registeredTestyard = true; + /* Register a second instance sharing pad1. */ + lua_State *L = app->getLuaState()->getState(); + if (luaL_dostring( + L, + "local s = require(\"sokoban\")\n" + "_G.testyard = s.new{ id = " + "\"yardtest\", pads = { " + "\"yard1.pad1\" }, questName = " + "\"Test Yard\" }\n") != LUA_OK) { + failed = true; + failReason = Ogre::String( + "lua error: ") + + lua_tostring(L, -1); + break; + } + /* Pull crate1 off pad1, far west where it cannot + * roll back onto a pad. */ + pullPos = Ogre::Vector3(4004.0f, 10.5f, 3944.5f); + teleportCrate("crate1", pullPos); + break; + } + /* Wait until the zone system registers pad1 uncovered. + * Poll the coverage state, NOT a fresh zone_left event: + * a settling bounce may already have consumed the + * inside->outside edge before the pull (the crate is + * dropped onto the pad and jiggles across the rest-speed + * threshold), in which case no new zone_left ever fires + * and the wait would stall until the phase timeout. */ + if (app->getZoneSystem()->isZoneCovered("yard1.pad1")) { + /* Pin the crate until uncovered so it cannot + * roll back onto a pad on the slope. */ + teleportCrate("crate1", pullPos); + break; + } + if (!GlobalStateStore::getInstance().getBool( + "quest.yard1.completed")) { + failed = true; + failReason = "yard1 lost its latched " + "completion after zone_left"; + break; + } + if (GlobalStateStore::getInstance().getBool( + "quest.yardtest.completed")) { + failed = true; + failReason = "yardtest completed without a " + "crate on its pad"; + break; + } + /* Put crate1 back on pad1: the second instance must + * complete independently (yard1 ignores events once + * latched). */ + bool ok = false; + Ogre::Vector3 c = zoneCenter("yard1.pad1", ok); + if (!ok) { + failed = true; + failReason = "yard1.pad1 vanished"; + break; + } + teleportCrate("crate1", c + Ogre::Vector3(0, 0.55f, 0)); + phaseStart = frame; + phase = 33; + break; + } + case 33: { + bool testCompleted = + GlobalStateStore::getInstance().getBool( + "quest.yardtest.completed"); + bool testEvent = false; + for (const Ogre::String &q : completedQuests) + if (q == "yardtest") + testEvent = true; + if (testCompleted && testEvent && + GlobalStateStore::getInstance().getBool( + "quest.yard1.completed")) { + std::cout << "[test] isolation: second " + "instance completed " + "independently, yard1 still " + "latched" + << std::endl; + std::cout << "[test] PASS" << std::endl; + phase = 4; + } + break; + } + } + return true; + } +}; + +/* + * End-to-end check for --test-sokoban-reset (yard reset): runs on the + * exterior scene, displaces crate1, teleports the player character + * 150m away from the yard and verifies the sokoban.lua frame-event + * reset teleports the crate back to its authored slot (quest still + * incomplete) and fires sokoban_reset; exits 0 on PASS. + */ +struct SokobanResetTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + /* 0: wait for world, 1: displace crate + move player away, + * 2: wait reset, 3: done. */ + int phase = 0; + bool failed = false; + Ogre::String failReason; + int phaseStart = 0; + Ogre::Vector3 slotPos = Ogre::Vector3::ZERO; + bool resetSeen = false; + EventBus::ListenerId subReset = 0; + + SokobanResetTestListener(EditorApp *a) + : app(a) + { + subReset = EventBus::getInstance().subscribe( + "sokoban_reset", + [this](const Ogre::String &, + const editScene::EventParams ¶ms) { + if (params.getString("yard") == "yard1") + resetSeen = true; + }); + } + + ~SokobanResetTestListener() + { + EventBus::getInstance().unsubscribe(subReset); + } + + flecs::entity findByName(const Ogre::String &name) + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == name) + result = e; + }); + return result; + } + + Ogre::Vector3 cratePos() + { + flecs::entity crate = findByName("crate1"); + if (!crate.is_alive() || !crate.has()) + return Ogre::Vector3::ZERO; + const RigidBodyComponent &rb = crate.get(); + if (!rb.bodyCreated) + return Ogre::Vector3::ZERO; + return app->getEditorPhysicsSystem() + ->getPhysicsWrapper() + ->getPosition(rb.bodyID); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 3) { + app->getRoot()->queueEndRendering(); + return true; + } + if (phase != 0 && frame - phaseStart > 4000) { + failed = true; + failReason = "timeout in sokoban reset test (phase " + + Ogre::StringConverter::toString(phase) + + ")"; + return true; + } + + switch (phase) { + case 0: { + if (frame < 90) + break; + flecs::entity crate = findByName("crate1"); + if (!crate.is_alive() || + !crate.has() || + !crate.get().bodyCreated) + break; + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive() || + !player.has()) + break; + slotPos = cratePos(); + phaseStart = frame; + phase = 1; + break; + } + case 1: { + /* Displace crate1 (the "moved crate") and walk + * the player off: 150m east of the yard. */ + flecs::entity crate = findByName("crate1"); + const RigidBodyComponent &rb = + crate.get(); + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + phys->setPosition( + rb.bodyID, + slotPos + Ogre::Vector3(6.0f, 0.0f, 0.0f), + true); + phys->setVelocities(rb.bodyID, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + + flecs::entity player = app->getPlayerCharacterEntity(); + auto &pt = player.get_mut(); + Ogre::Vector3 away = slotPos + + Ogre::Vector3(150.0f, 20.0f, + 0.0f); + if (pt.node) + pt.node->setPosition(away); + pt.position = away; + std::cout << "[test] reset-test: crate1 displaced, " + "player moved 150m away" + << std::endl; + phaseStart = frame; + phase = 2; + break; + } + case 2: { + Ogre::Vector3 p = cratePos(); + if ((p - slotPos).length() < 1.0f) { + if (!resetSeen) { + failed = true; + failReason = "crate1 back in its slot " + "but no sokoban_reset event"; + break; + } + if (GlobalStateStore::getInstance().getBool( + "quest.yard1.completed")) { + failed = true; + failReason = "quest completed during " + "the reset test"; + break; + } + std::cout << "[test] reset-test: crate1 back " + "in its slot, sokoban_reset " + "received" + << std::endl; + std::cout << "[test] PASS" << std::endl; + phase = 3; + } + break; + } + } + return true; + } +}; + /* * demo-sokoban: starts as an exact copy of * demos/demo-interior-exterior-dynamics; a forklift sokoban activity @@ -1125,9 +2576,10 @@ struct VehicleTestListener : public Ogre::FrameListener { * (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the * runtime config JSONs are staged next to it by the build; both scene * JSONs are symlinks into the source tree, so scene edits take effect - * without a rebuild; the terrain heightmap is staged to - * heightmaps/4242424300000001/ by configure_file, re-copied automatically - * when the source file changes). + * without a rebuild; animation_tree.json is demo-owned — copied from the + * source tree, versioned with the demo — and the terrain heightmap is + * staged to heightmaps/4242424300000001/ by configure_file, re-copied + * automatically when the source file changes). * * Extra flags: * --test-switch headless-friendly end-to-end check: verifies the F6 @@ -1150,6 +2602,34 @@ struct VehicleTestListener : public Ogre::FrameListener { * the VehicleComponent input fields and verifies it * moved, stayed upright and stops again once the input * is released; exits 0 on PASS. + * --test-vehicle-drive + * headless-friendly F10 enter/exit check: loads the + * exterior scene, sends the "vehicle_enter" event (the + * same one the forklift actuator's vehicle_drive action + * sends through the vehicleEnter Lua node), drives the + * forklift by holding the game-mode W input, then exits + * via VehicleControllerSystem::exitVehicle() and checks + * the character is back on foot next to the moved + * forklift; exits 0 on PASS. + * --test-crate headless-friendly F11 crate check: loads the + * flat-floor demo_scene_vehicletest.json, waits for + * "crate1" (PushableComponent + dynamic rigid body) to + * settle, kicks it with a physics impulse and verifies + * it slides, stays upright and settles again on the + * floor; exits 0 on PASS. + * --test-fork headless-friendly F10 fork check: loads the + * flat-floor demo_scene_vehicletest.json, aligns the + * forklift with "crate1", raises the fork (the crate + * attaches and rides up), drives with it, lowers the + * fork (release) and drives away leaving the crate + * behind; exits 0 on PASS. + * --test-sokoban-reset + * headless-friendly yard-reset check: loads the + * exterior scene, displaces crate1, moves the player + * 150m from the yard and verifies the sokoban.lua + * frame-event reset teleports the crate back to its + * slot (quest still incomplete) and fires + * sokoban_reset; exits 0 on PASS. */ int main(int argc, char *argv[]) { @@ -1178,6 +2658,11 @@ int main(int argc, char *argv[]) bool exitAfterFirstFrame = false; bool testSwitch = false; bool testVehicle = false; + bool testVehicleDrive = false; + bool testCrate = false; + bool testFork = false; + bool testSokoban = false; + bool testSokobanReset = false; bool sceneArgGiven = false; Ogre::String shotFile; int shotFrame = -1; @@ -1185,6 +2670,10 @@ int main(int argc, char *argv[]) Ogre::Vector3 forcePosVec = Ogre::Vector3::ZERO; float forcePosYaw = 0.0f; float forcePosPitch = 0.0f; + Ogre::String debugEnterVehicle; + bool probeHeights = false; + double probeCx = 0.0, probeCz = 0.0; + double probeHalf = 0.0, probeStep = 1.0; #ifdef EDITSCENE_HAS_EMBEDDED_PROJECT Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE; #else @@ -1200,6 +2689,27 @@ int main(int argc, char *argv[]) testSwitch = true; } else if (arg == "--test-vehicle") { testVehicle = true; + } else if (arg == "--test-vehicle-drive") { + testVehicleDrive = true; + } else if (arg == "--test-crate") { + testCrate = true; + } else if (arg == "--test-fork") { + testFork = true; + } else if (arg == "--test-sokoban") { + testSokoban = true; + } else if (arg == "--test-sokoban-reset") { + testSokobanReset = true; + } else if (arg == "--debug-enter-vehicle" && + i + 1 < argc) { + debugEnterVehicle = argv[i + 1]; + i += 1; + } else if (arg == "--probe-heights" && i + 4 < argc) { + probeHeights = true; + probeCx = atof(argv[i + 1]); + probeCz = atof(argv[i + 2]); + probeHalf = atof(argv[i + 3]); + probeStep = atof(argv[i + 4]); + i += 4; } else if (arg == "--debug-buoyancy") { app.setDebugBuoyancy(true); } else if (arg == "--screenshot" && i + 2 < argc) { @@ -1232,10 +2742,19 @@ int main(int argc, char *argv[]) sceneArgGiven = true; } } - /* The vehicle test drives the forklift parked in the exterior + /* The vehicle tests drive the forklift parked in the exterior * scene, so load that scene directly (unless the caller gave * an explicit scene path). */ - if (testVehicle && !sceneArgGiven) + if ((testVehicle || testVehicleDrive) && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; + /* The crate/fork tests use the flat-floor scene. */ + if ((testCrate || testFork) && !sceneArgGiven) + sceneFile = "demo_scene_vehicletest.json"; + /* The sokoban tests play the yard in the exterior scene. */ + if ((testSokoban || testSokobanReset) && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; + /* Height probing needs the streaming terrain. */ + if (probeHeights && !sceneArgGiven) sceneFile = "demo_scene_exterior.json"; app.setHeadless(headless); if (headless) { @@ -1276,7 +2795,9 @@ int main(int argc, char *argv[]) << std::endl; ExitAfterFirstFrameListener exitListener(app.getRoot()); - if (exitAfterFirstFrame && !testSwitch && !testVehicle) + if (exitAfterFirstFrame && !testSwitch && !testVehicle && + !testVehicleDrive && !testCrate && !testFork && + !testSokoban && !testSokobanReset) app.getRoot()->addFrameListener(&exitListener); SceneSwitchTestListener testListener(&app); @@ -1287,6 +2808,26 @@ int main(int argc, char *argv[]) if (testVehicle) app.getRoot()->addFrameListener(&vehicleListener); + VehicleDriveTestListener vehicleDriveListener(&app); + if (testVehicleDrive) + app.getRoot()->addFrameListener(&vehicleDriveListener); + + CrateTestListener crateListener(&app); + if (testCrate) + app.getRoot()->addFrameListener(&crateListener); + + ForkTestListener forkListener(&app); + if (testFork) + app.getRoot()->addFrameListener(&forkListener); + + SokobanTestListener sokobanListener(&app); + if (testSokoban) + app.getRoot()->addFrameListener(&sokobanListener); + + SokobanResetTestListener sokobanResetListener(&app); + if (testSokobanReset) + app.getRoot()->addFrameListener(&sokobanResetListener); + DebugCaptureListener captureListener(&app); if (forcePos) { captureListener.teleportEnabled = true; @@ -1297,7 +2838,14 @@ int main(int argc, char *argv[]) if (shotFrame > 0 && !shotFile.empty()) captureListener.shotFrame = shotFrame; captureListener.shotFile = shotFile; - if (forcePos || captureListener.shotFrame > 0) + captureListener.enterVehicleName = debugEnterVehicle; + captureListener.probeEnabled = probeHeights; + captureListener.probeCx = probeCx; + captureListener.probeCz = probeCz; + captureListener.probeHalf = probeHalf; + captureListener.probeStep = probeStep; + if (forcePos || captureListener.shotFrame > 0 || + !debugEnterVehicle.empty() || probeHeights) app.getRoot()->addFrameListener(&captureListener); app.getRoot()->startRendering(); @@ -1339,6 +2887,106 @@ int main(int argc, char *argv[]) } } + if (testVehicleDrive) { + if (vehicleDriveListener.failed) { + std::cerr << "[test] FAIL: " + << vehicleDriveListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (vehicleDriveListener.phase != 4) { + std::cerr << "[test] FAIL: incomplete (vehicle " + "drive phase " + << vehicleDriveListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testCrate) { + if (crateListener.failed) { + std::cerr << "[test] FAIL: " + << crateListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (crateListener.phase != 4) { + std::cerr << "[test] FAIL: incomplete (crate " + "phase " + << crateListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testFork) { + if (forkListener.failed) { + std::cerr << "[test] FAIL: " + << forkListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (forkListener.phase != 8) { + std::cerr << "[test] FAIL: incomplete (fork " + "phase " + << forkListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testSokoban) { + if (sokobanListener.failed) { + std::cerr << "[test] FAIL: " + << sokobanListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (sokobanListener.phase != 4) { + std::cerr << "[test] FAIL: incomplete (sokoban " + "phase " + << sokobanListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testSokobanReset) { + if (sokobanResetListener.failed) { + std::cerr << "[test] FAIL: " + << sokobanResetListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (sokobanResetListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete (sokoban " + "reset phase " + << sokobanResetListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + /* Destroy scene entities while the systems are still alive: * CharacterSpawnerSystem registers an OnRemove observer on * CharacterSpawnerComponent that dereferences the system, and diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json b/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json index 4e40ef5..4c56799 100644 --- a/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json @@ -44,6 +44,28 @@ "mask": 0 } }, + { + "behaviorTree": { + "children": [ + { + "name": "vehicleEnter", + "params": "vehicle=forklift1", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "vehicle_drive", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, { "behaviorTree": { "children": [ @@ -785,13 +807,13 @@ "z": 0.0 }, "scale": { - "x": 0.7, - "y": 0.45, - "z": 1.1 + "x": 1.0, + "y": 1.0, + "z": 1.0 } }, "renderable": { - "meshName": "Cube.mesh", + "meshName": "forklift.mesh", "visible": true }, "collider": { @@ -799,7 +821,7 @@ "parameters": { "x": 0.7, "y": 0.3, - "z": 1.2 + "z": 1.3 }, "radius": 0.5, "halfHeight": 1.0, @@ -807,7 +829,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 0.0 + "z": -0.3 }, "rotationOffset": { "w": 1.0, @@ -829,10 +851,29 @@ "maxPitchRollAngleDeg": 60.0, "seatOffset": { "x": 0.0, - "y": 1.0, - "z": 0.0 + "y": -0.07, + "z": -0.85 }, - "wheelMeshName": "", + "wheelMeshName": "forklift-wheel.mesh", + "steeringWheelMeshName": "forklift-steering-wheel.mesh", + "steeringWheelOffset": { + "x": 0.0, + "y": 0.75, + "z": -0.3 + }, + "steeringWheelTiltDeg": 65.0, + "forkMeshName": "forklift-forks.mesh", + "forkOffset": { + "x": 0.0, + "y": 0.0, + "z": 1.17 + }, + "forkMaxHeight": 1.2, + "forkSpeed": 0.8, + "steeringRatio": 4.0, + "cameraDistance": 5.0, + "cameraHeight": 1.2, + "rearSteer": true, "wheels": [ { "position": { @@ -899,6 +940,642 @@ "maxHandBrakeTorque": 200.0 } ] + }, + "actuator": { + "radius": 2.5, + "height": 2.0, + "actionNames": [ + "vehicle_drive" + ] + } + }, + { + "children": [], + "id": 501, + "name": { + "name": "crate1" + }, + "transform": { + "position": { + "x": 4009.0, + "y": 10.49, + "z": 3945.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "crate.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "dynamic", + "mass": 40.0, + "friction": 0.6, + "restitution": 0.0, + "isSensor": false, + "enabled": true + }, + "pushable": { + "enabled": true + } + }, + { + "children": [], + "id": 502, + "name": { + "name": "crate2" + }, + "transform": { + "position": { + "x": 4012.0, + "y": 10.51, + "z": 3945.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "crate.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "dynamic", + "mass": 40.0, + "friction": 0.6, + "restitution": 0.0, + "isSensor": false, + "enabled": true + }, + "pushable": { + "enabled": true + } + }, + { + "children": [], + "id": 503, + "name": { + "name": "crate3" + }, + "transform": { + "position": { + "x": 4015.0, + "y": 10.53, + "z": 3945.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "crate.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "dynamic", + "mass": 40.0, + "friction": 0.6, + "restitution": 0.0, + "isSensor": false, + "enabled": true + }, + "pushable": { + "enabled": true + } + }, + { + "children": [], + "id": 504, + "name": { + "name": "pad1" + }, + "transform": { + "position": { + "x": 4008.0, + "y": 9.966, + "z": 3941.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "pad.mesh", + "visible": true + }, + "targetZone": { + "zoneId": "yard1.pad1", + "halfExtents": { + "x": 0.6, + "y": 0.1, + "z": 0.6 + }, + "uncoveredMaterialName": "pad", + "coveredMaterialName": "pad-covered", + "enabled": true + } + }, + { + "children": [], + "id": 505, + "name": { + "name": "pad2" + }, + "transform": { + "position": { + "x": 4012.0, + "y": 9.993, + "z": 3941.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "pad.mesh", + "visible": true + }, + "targetZone": { + "zoneId": "yard1.pad2", + "halfExtents": { + "x": 0.6, + "y": 0.1, + "z": 0.6 + }, + "uncoveredMaterialName": "pad", + "coveredMaterialName": "pad-covered", + "enabled": true + } + }, + { + "children": [], + "id": 506, + "name": { + "name": "pad3" + }, + "transform": { + "position": { + "x": 4016.0, + "y": 10.02, + "z": 3941.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "pad.mesh", + "visible": true + }, + "targetZone": { + "zoneId": "yard1.pad3", + "halfExtents": { + "x": 0.6, + "y": 0.1, + "z": 0.6 + }, + "uncoveredMaterialName": "pad", + "coveredMaterialName": "pad-covered", + "enabled": true + } + }, + { + "children": [], + "id": 507, + "name": { + "name": "yard_wall_north" + }, + "transform": { + "position": { + "x": 4012.0, + "y": 10.379, + "z": 3939.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 13.0, + "y": 1.6, + "z": 0.4 + } + }, + "renderable": { + "meshName": "wall.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 6.5, + "y": 0.8, + "z": 0.2 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + } + }, + { + "children": [], + "id": 508, + "name": { + "name": "yard_wall_west" + }, + "transform": { + "position": { + "x": 4006.0, + "y": 10.325, + "z": 3944.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 0.4, + "y": 1.6, + "z": 10.0 + } + }, + "renderable": { + "meshName": "wall.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.2, + "y": 0.8, + "z": 5.0 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + } + }, + { + "children": [], + "id": 509, + "name": { + "name": "yard_wall_east" + }, + "transform": { + "position": { + "x": 4018.0, + "y": 10.405, + "z": 3944.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 0.4, + "y": 1.6, + "z": 10.0 + } + }, + "renderable": { + "meshName": "wall.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.2, + "y": 0.8, + "z": 5.0 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + } + }, + { + "children": [], + "id": 510, + "name": { + "name": "yard_wall_south_west" + }, + "transform": { + "position": { + "x": 4008.25, + "y": 10.332, + "z": 3949.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 4.5, + "y": 1.6, + "z": 0.4 + } + }, + "renderable": { + "meshName": "wall.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 2.25, + "y": 0.8, + "z": 0.2 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + } + }, + { + "children": [], + "id": 511, + "name": { + "name": "yard_wall_south_east" + }, + "transform": { + "position": { + "x": 4015.75, + "y": 10.352, + "z": 3949.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 4.5, + "y": 1.6, + "z": 0.4 + } + }, + "renderable": { + "meshName": "wall.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 2.25, + "y": 0.8, + "z": 0.2 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + } + }, + { + "children": [], + "id": 512, + "name": { + "name": "yard1" + }, + "transform": { + "position": { + "x": 4012.0, + "y": 10.0, + "z": 3944.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "sceneScript": { + "inlineScript": "-- Sokoban yard controller (F11): registers this yard with the\n-- shared rules module (lua-scripts/sokoban.lua). Zone detection\n-- is engine-level (ZoneSystem); this instance tracks coverage and\n-- latches the \"Crate Yard\" quest completion. The crates/anchor\n-- config enables the yard reset: leaving an incomplete, disturbed\n-- yard (100m) returns the crates to their slots.\nlocal sokoban = require(\"sokoban\")\nsokoban.new{\n id = \"yard1\",\n pads = { \"yard1.pad1\", \"yard1.pad2\", \"yard1.pad3\" },\n questName = \"Crate Yard\",\n crates = { \"crate1\", \"crate2\", \"crate3\" },\n anchor = \"yard1\",\n resetDistance = 100,\n}" } } ], diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json b/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json index e157fce..d930408 100644 --- a/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json @@ -339,13 +339,13 @@ "z": 0.0 }, "scale": { - "x": 0.7, - "y": 0.45, - "z": 1.1 + "x": 1.0, + "y": 1.0, + "z": 1.0 } }, "renderable": { - "meshName": "Cube.mesh", + "meshName": "forklift.mesh", "visible": true }, "collider": { @@ -353,7 +353,7 @@ "parameters": { "x": 0.7, "y": 0.3, - "z": 1.2 + "z": 1.3 }, "radius": 0.5, "halfHeight": 1.0, @@ -361,7 +361,7 @@ "offset": { "x": 0.0, "y": 0.0, - "z": 0.0 + "z": -0.3 }, "rotationOffset": { "w": 1.0, @@ -383,10 +383,29 @@ "maxPitchRollAngleDeg": 60.0, "seatOffset": { "x": 0.0, - "y": 1.0, - "z": 0.0 + "y": -0.07, + "z": -0.85 }, - "wheelMeshName": "", + "wheelMeshName": "forklift-wheel.mesh", + "steeringWheelMeshName": "forklift-steering-wheel.mesh", + "steeringWheelOffset": { + "x": 0.0, + "y": 0.75, + "z": -0.3 + }, + "steeringWheelTiltDeg": 65.0, + "forkMeshName": "forklift-forks.mesh", + "forkOffset": { + "x": 0.0, + "y": 0.0, + "z": 1.17 + }, + "forkMaxHeight": 1.2, + "forkSpeed": 0.8, + "steeringRatio": 4.0, + "cameraDistance": 5.0, + "cameraHeight": 1.2, + "rearSteer": true, "wheels": [ { "position": { @@ -454,6 +473,68 @@ } ] } + }, + { + "children": [], + "id": 501, + "name": { + "name": "crate1" + }, + "transform": { + "position": { + "x": 3.0, + "y": 0.6, + "z": 6.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "crate.mesh", + "visible": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "rigidBody": { + "bodyType": "dynamic", + "mass": 40.0, + "friction": 0.6, + "restitution": 0.0, + "isSensor": false, + "enabled": true + }, + "pushable": { + "enabled": true + } } ], "version": "1.0" diff --git a/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake b/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake index f0c056c..1437bff 100644 --- a/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake +++ b/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake @@ -53,14 +53,18 @@ endforeach() # Runtime config JSONs loaded at startup relative to the CWD (game mode # reads startup_menu.json, the registries read the rest — the demo needs -# character_registry.json for the spawner's registryId 2 and -# animation_tree.json for the character's "male1_6" animation tree). They +# character_registry.json for the spawner's registryId 2). They # only exist after the editor/game has run once; copy them when present so # the demo behaves the same as when run from the editor binary directory. foreach(f startup_menu.json character_registry.json character_class.json - items.json item_state.json inventory_config.json - animation_tree.json) + items.json item_state.json inventory_config.json) if(EXISTS "${EDITSCENE_BIN}/${f}") file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}") endif() endforeach() + +# animation_tree.json is demo-owned (source tree, versioned with the demo; +# it carries the "driving" locomotion state used by the forklift seat). +# COPIED, not symlinked: AnimationTreeRegistry auto-saves on runtime tree +# migrations and must not write through into the source tree. +file(COPY "${SRC_DIR}/animation_tree.json" DESTINATION "${DEMO_DIR}") diff --git a/src/features/editScene/lua-examples/sokoban_example.lua b/src/features/editScene/lua-examples/sokoban_example.lua new file mode 100644 index 0000000..19364bd --- /dev/null +++ b/src/features/editScene/lua-examples/sokoban_example.lua @@ -0,0 +1,57 @@ +-- ============================================================================= +-- Sokoban Yard Example (F11, see demos/demo-sokoban/PLAN.md) +-- ============================================================================= +-- A sokoban yard is an open-world activity: pushable crates +-- (PushableComponent), target pads (TargetZoneComponent, zone detection is +-- engine-level in ZoneSystem) and this Lua rules module tracking coverage +-- and latching quest completion into the global state store. +-- +-- Scene setup needed for this example: +-- * entities "crate1", "crate2" with PushableComponent + dynamic body +-- * zones "yard1.pad1", "yard1.pad2" (TargetZoneComponent on pad +-- entities; covered/uncovered material swap is optional feedback) +-- * a static entity "yard1" as the yard controller + reset anchor +-- (this script is its SceneScriptComponent inline script) +-- ============================================================================= + +local sokoban = require("sokoban") + +local game = sokoban.new{ + id = "yard1", -- unique yard/quest id + pads = { "yard1.pad1", "yard1.pad2" }, -- TargetZoneComponent zone ids + questName = "Crate Yard", -- human-readable quest name + + -- Optional yard reset: when the player leaves an incomplete, + -- disturbed yard (beyond resetDistance from the anchor), all crates + -- teleport back to their registration slots and "sokoban_reset" + -- fires. Omit `crates` to disable the reset entirely. + crates = { "crate1", "crate2" }, + anchor = "yard1", + resetDistance = 100, + + onCompleted = function(inst) + print("yard " .. inst.id .. " done!") + end, +} + +-- Progress is published through the persistent global store: +-- quest.yard1.progress (int) pads currently covered +-- quest.yard1.completed (bool) latched once all pads are covered +-- +-- ...through events: +-- sokoban_progress { yard = "yard1", covered = n, total = m } +-- quest_completed { quest_id = "yard1", quest_name = "Crate Yard" } +-- sokoban_reset { yard = "yard1" } +-- +-- ...and through the game HUD (status line "Crate Yard: crates n/m" and +-- a completion banner; no-op without a HUD). + +-- Reacting to completion (e.g. to unlock a reward door): +ecs.subscribe_event("quest_completed", function(event, params) + if params.quest_id == "yard1" then + print("Quest completed: " .. (params.quest_name or "?")) + end +end) + +-- The instance can also be queried directly: +-- local covered, total = game.progress() diff --git a/src/features/editScene/lua-scripts/data2.lua b/src/features/editScene/lua-scripts/data2.lua index 9d5125a..1ff28be 100644 --- a/src/features/editScene/lua-scripts/data2.lua +++ b/src/features/editScene/lua-scripts/data2.lua @@ -6,6 +6,14 @@ ecs.behavior_tree.register_node("luaHello", function(entity_id, params) return "success" end) +-- F10 vehicles: seat the player character on the named vehicle entity +-- (VehicleControllerSystem listens for "vehicle_enter"). +ecs.behavior_tree.register_node("vehicleEnter", function(entity_id, params) + local vehicle = params.vehicle or "" + ecs.send_event("vehicle_enter", { vehicle = vehicle }) + return "success" +end) + ecs.action_db.add_action("lua_hello_action", 1, {}, -- no preconditions {}, -- no effects diff --git a/src/features/editScene/lua-scripts/sokoban.lua b/src/features/editScene/lua-scripts/sokoban.lua new file mode 100644 index 0000000..94df64e --- /dev/null +++ b/src/features/editScene/lua-scripts/sokoban.lua @@ -0,0 +1,316 @@ +-- ============================================================================= +-- Sokoban activity rules (F11, see demos/demo-sokoban/PLAN.md Phase 6) +-- ============================================================================= +-- Shared module, loaded from the LuaScripts resource group: +-- +-- local sokoban = require("sokoban") +-- local game = sokoban.new{ +-- id = "yard1", -- unique yard/quest id +-- pads = { "yard1.pad1", "yard1.pad2" }, -- TargetZoneComponent zone ids +-- questName = "Crate Yard", -- human-readable name +-- onCompleted = function(inst) ... end, -- optional callback +-- crates = { "crate1", "crate2" }, -- optional: crate entity +-- -- names; enables yard reset +-- anchor = "yard1", -- static reference entity +-- resetDistance = 100, -- player distance (m) that +-- -- triggers the reset +-- } +-- +-- Yard reset: with `crates` set, the instance records each crate's +-- offset from the (static) anchor entity at registration — offsets are +-- invariant under render-origin rebases. While the quest is +-- incomplete, a player farther than resetDistance from the anchor with +-- at least one displaced crate teleports all crates back to their +-- slots (rotation reset to yaw 0) and fires: +-- +-- sokoban_reset { yard = } +-- +-- A crate carried on a vehicle fork at that moment is yanked free by +-- the teleport (VehicleSystem drops the carry on external teleports). +-- +-- The instance subscribes to the engine-level zone_entered/zone_left +-- events (ZoneSystem) and tracks which pads are covered by resting +-- pushable props. Coverage is tracked per pad as a set of occupant +-- entity names, so two props on one pad still count once, and a pad is +-- only uncovered when its last occupant leaves. When the engine +-- provides ecs.is_zone_covered (wired to ZoneSystem::isZoneCovered), +-- coverage is read from that authoritative state instead — the same +-- state that paints a pad green — and a throttled frame tick +-- reconciles the counters so a missed event edge can never stall +-- completion with all pads visibly green. +-- +-- Progress is published through the persistent global store (survives +-- scene switches and saves, F9): +-- +-- quest..progress (int) pads currently covered +-- quest..completed (bool) latched once all pads are covered +-- +-- ...and through events: +-- +-- sokoban_progress { yard = , covered = n, total = m } +-- quest_completed { quest_id = , quest_name = } +-- +-- ...and through the game HUD (F12, ecs.hud.*, no-op without a HUD): +-- +-- status key "sokoban." -> ": crates n/m" +-- completion banner -> "Quest completed: " +-- +-- Completion latches: once completed the instance ignores further zone +-- events (a classic sokoban yard does not "un-complete"). +-- ============================================================================= + +local sokoban = {} + +-- ecs.hud is optional (unit tests, headless without HUD wiring). +local function hudStatus(key, text) + if ecs.hud then + ecs.hud.set_status(key, text) + end +end + +local function hudMessage(text, ttl) + if ecs.hud then + ecs.hud.push_message(text, ttl) + end +end + +function sokoban.new(config) + assert(type(config) == "table" and type(config.id) == "string", + "sokoban.new: config.id (string) required") + assert(type(config.pads) == "table" and #config.pads > 0, + "sokoban.new: config.pads (array of zone ids) required") + + local inst = {} + inst.id = config.id + inst.questName = config.questName or config.id + inst.onCompleted = config.onCompleted + inst.padSet = {} + inst.occupants = {} -- zone_id -> { [entityName] = true } + inst.completed = ecs.global.get_bool("quest." .. config.id .. + ".completed") + + for _, zoneId in ipairs(config.pads) do + inst.padSet[zoneId] = true + inst.occupants[zoneId] = {} + end + + -- Baseline latch: pads already covered at registration (a crate + -- resting there before this instance subscribed — no fresh + -- zone_entered edge will ever fire for it) must NOT count towards + -- this instance's completion. The latch clears the first time the + -- pad reads uncovered, after which the pad counts normally. + inst.preCovered = {} + if ecs.is_zone_covered then + for zoneId in pairs(inst.padSet) do + if ecs.is_zone_covered(zoneId) then + inst.preCovered[zoneId] = true + end + end + end + + -- Authoritative coverage: prefer the ZoneSystem state (the same + -- state that paints a pad green) over the event-tracked occupant + -- sets, so a missed zone_entered/zone_left edge can never leave the + -- counter stuck below the visible green pads. + local function padCovered(zoneId) + if ecs.is_zone_covered then + local covered = ecs.is_zone_covered(zoneId) and true or + false + if not covered then + inst.preCovered[zoneId] = nil + return false + end + return not inst.preCovered[zoneId] + end + return next(inst.occupants[zoneId]) ~= nil + end + + local function coveredCount() + local n = 0 + for zoneId in pairs(inst.padSet) do + if padCovered(zoneId) then + n = n + 1 + end + end + return n + end + + function inst.progress() + return coveredCount(), #config.pads + end + + inst.lastPublished = -1 + + local function refresh() + local covered, total = inst.progress() + if covered ~= inst.lastPublished then + inst.lastPublished = covered + ecs.global.set("quest." .. inst.id .. ".progress", covered) + hudStatus("sokoban." .. inst.id, + inst.questName .. ": crates " .. covered .. + "/" .. total) + ecs.send_event("sokoban_progress", { + yard = inst.id, covered = covered, + total = total }) + end + if not inst.completed and covered == total then + inst.completed = true + ecs.global.set("quest." .. inst.id .. ".completed", true) + hudMessage("Quest completed: " .. inst.questName, 8) + ecs.send_event("quest_completed", { + quest_id = inst.id, + quest_name = inst.questName }) + print("[sokoban] yard " .. inst.id .. " completed (" .. + total .. "/" .. total .. ")") + if inst.onCompleted then + pcall(inst.onCompleted, inst) + end + end + end + + local function onZoneEvent(event, params) + if inst.completed or not params or not params.zone_id then + return + end + local zoneId = params.zone_id + if not inst.padSet[zoneId] then + return -- some other yard's zone: multi-instance isolation + end + local who = params.entity or "?" + if event == "zone_entered" then + inst.occupants[zoneId][who] = true + else + inst.occupants[zoneId][who] = nil + end + refresh() + end + + ecs.subscribe_event("zone_entered", onZoneEvent) + ecs.subscribe_event("zone_left", onZoneEvent) + + -- Reconcile against the authoritative zone state a few times per + -- second: an event edge can be missed (e.g. a crate already resting + -- on a pad before this script subscribed, or a teleport without a + -- matching zone_left), which would otherwise leave every pad green + -- without completion ever firing. + local reconcileThrottle = 0 + ecs.subscribe_event("frame", function(event, params) + if inst.completed then + return + end + reconcileThrottle = reconcileThrottle + + (params and params.dt or 0.016) + if reconcileThrottle < 0.25 then + return + end + reconcileThrottle = 0 + refresh() + end) + + -- Optional yard reset (see header): crates return to their slots + -- when the player leaves an incomplete, disturbed yard. + if config.crates and #config.crates > 0 then + assert(type(config.anchor) == "string", + "sokoban.new: config.anchor (entity name) required " .. + "with config.crates") + local anchorId = ecs.get_entity_by_name(config.anchor) + assert(anchorId, "sokoban.new: anchor entity '" .. + config.anchor .. "' not found") + local anchorPos = ecs.get_entity_position(anchorId) + assert(anchorPos, "sokoban.new: anchor entity '" .. + config.anchor .. "' has no position") + + local crateIds = {} + local offsets = {} + for i, name in ipairs(config.crates) do + local cid = ecs.get_entity_by_name(name) + assert(cid, "sokoban.new: crate entity '" .. name .. + "' not found") + local p = ecs.get_entity_position(cid) + assert(p, "sokoban.new: crate entity '" .. name .. + "' has no position") + crateIds[i] = cid + offsets[i] = { x = p.x - anchorPos.x, + y = p.y - anchorPos.y, + z = p.z - anchorPos.z } + end + + local resetDistance = config.resetDistance or 100 + local throttle = 0 + ecs.subscribe_event("frame", function(event, params) + if inst.completed then + return + end + throttle = throttle + (params and params.dt or 0.016) + if throttle < 0.5 then + return + end + throttle = 0 + + local anchor = ecs.get_entity_position(anchorId) + if not anchor then + return + end + local playerId = + ecs.get_player_character and + ecs.get_player_character() or + ecs.get_player_entity() + local playerPos = playerId and + ecs.get_entity_position(playerId) + if not playerPos then + return + end + local pdx = playerPos.x - anchor.x + local pdz = playerPos.z - anchor.z + if pdx * pdx + pdz * pdz < + resetDistance * resetDistance then + return + end + + -- Any crate displaced from its slot? + local moved = false + for i, cid in ipairs(crateIds) do + local p = ecs.get_entity_position(cid) + if p then + local dx = p.x - + (anchor.x + offsets[i].x) + local dy = p.y - + (anchor.y + offsets[i].y) + local dz = p.z - + (anchor.z + offsets[i].z) + if dx * dx + dy * dy + dz * dz > + 0.25 then + moved = true + break + end + end + end + if not moved then + return + end + + for i, cid in ipairs(crateIds) do + ecs.teleport_entity(cid, + anchor.x + offsets[i].x, + anchor.y + offsets[i].y, + anchor.z + offsets[i].z, 0) + end + hudMessage(inst.questName .. ": crates reset", 5) + ecs.send_event("sokoban_reset", { yard = inst.id }) + print("[sokoban] yard " .. inst.id .. + " reset (player left)") + end) + end + + -- Initial HUD line (0/m unless the quest is already done). + local covered, total = inst.progress() + hudStatus("sokoban." .. inst.id, inst.questName .. ": crates " .. + (inst.completed and total or covered) .. "/" .. total) + + print("[sokoban] registered yard " .. inst.id .. " with " .. + #config.pads .. " pads" .. + (inst.completed and " (already completed)" or "")) + return inst +end + +return sokoban diff --git a/src/features/editScene/lua/LuaEntityApi.cpp b/src/features/editScene/lua/LuaEntityApi.cpp index 366f276..9ba6c9d 100644 --- a/src/features/editScene/lua/LuaEntityApi.cpp +++ b/src/features/editScene/lua/LuaEntityApi.cpp @@ -10,6 +10,42 @@ namespace editScene // Global entity ID map LuaEntityIdMap g_luaEntityIdMap; +/* Injected by EditorApp so this file stays free of EditorApp/physics/ + * TransformComponent dependencies — the standalone Lua test targets + * compile it against the stub Ogre.h in tests/. */ +static std::function s_playerCharacterResolver; +static std::function + s_entityPositionGetter; +static std::function + s_entityTeleporter; +static std::function s_zoneCoverageChecker; + +void setLuaPlayerCharacterResolver( + const std::function &resolver) +{ + s_playerCharacterResolver = resolver; +} + +void setLuaEntityPositionGetter( + const std::function &getter) +{ + s_entityPositionGetter = getter; +} + +void setLuaEntityTeleporter( + const std::function &teleporter) +{ + s_entityTeleporter = teleporter; +} + +void setLuaZoneCoverageChecker( + const std::function &checker) +{ + s_zoneCoverageChecker = checker; +} + int luaEntityToId(flecs::entity e) { if (!e.is_valid()) @@ -204,6 +240,111 @@ static int luaGetChildren(lua_State *L) return 1; } +// --------------------------------------------------------------------------- +// Lua: ecs.get_player_character() -> int (entity ID) or nil +// The live player character (the controller's current target, spawning +// it through CharacterSpawnerSystem when needed) — unlike +// get_player_entity(), which returns the stationary controller entity. +// --------------------------------------------------------------------------- + +static int luaGetPlayerCharacter(lua_State *L) +{ + flecs::entity e; + if (s_playerCharacterResolver) + e = s_playerCharacterResolver(); + if (e.is_valid() && e.is_alive()) { + lua_pushinteger(L, g_luaEntityIdMap.addEntity(e)); + } else { + lua_pushnil(L); + } + return 1; +} + +// --------------------------------------------------------------------------- +// Lua: ecs.get_entity_position(id) -> {x,y,z} or nil +// Render-space position (scene node when available). +// --------------------------------------------------------------------------- + +static int luaGetEntityPosition(lua_State *L) +{ + luaL_checktype(L, 1, LUA_TNUMBER); + int id = lua_tointeger(L, 1); + if (!g_luaEntityIdMap.hasId(id) || !s_entityPositionGetter) { + lua_pushnil(L); + return 1; + } + flecs::entity e = g_luaEntityIdMap.getEntity(id); + Ogre::Vector3 pos; + if (!e.is_alive() || !s_entityPositionGetter(e, pos)) { + lua_pushnil(L); + return 1; + } + lua_newtable(L); + lua_pushnumber(L, pos.x); + lua_setfield(L, -2, "x"); + lua_pushnumber(L, pos.y); + lua_setfield(L, -2, "y"); + lua_pushnumber(L, pos.z); + lua_setfield(L, -2, "z"); + return 1; +} + +// --------------------------------------------------------------------------- +// Lua: ecs.teleport_entity(id, x, y, z [, yawDeg]) -> bool +// Moves an entity to a render-space position; the actual work is done by +// the injected teleporter (EditorApp.cpp): with a live rigid body the +// body is teleported (world-space under the hood, velocities zeroed) and +// the scene node follows on the next physics sync; otherwise the +// transform/node is moved directly. yawDeg, when given, resets the +// rotation to that yaw (otherwise the rotation is kept). +// --------------------------------------------------------------------------- + +static int luaTeleportEntity(lua_State *L) +{ + luaL_checktype(L, 1, LUA_TNUMBER); + int id = lua_tointeger(L, 1); + Ogre::Vector3 pos((float)luaL_checknumber(L, 2), + (float)luaL_checknumber(L, 3), + (float)luaL_checknumber(L, 4)); + bool hasYaw = lua_isnumber(L, 5); + float yawDeg = (float)lua_tonumber(L, 5); + + if (!g_luaEntityIdMap.hasId(id) || !s_entityTeleporter) { + lua_pushboolean(L, 0); + return 1; + } + flecs::entity e = g_luaEntityIdMap.getEntity(id); + if (!e.is_alive()) { + lua_pushboolean(L, 0); + return 1; + } + + lua_pushboolean(L, + s_entityTeleporter(e, pos, hasYaw, yawDeg) ? 1 : 0); + return 1; +} + +// --------------------------------------------------------------------------- +// Lua: ecs.is_zone_covered(zoneId) -> bool or nil +// Authoritative zone coverage straight from ZoneSystem (the same state +// that drives the zone's covered material), so Lua-side activity rules +// can reconcile against it instead of relying purely on the +// zone_entered/zone_left event stream. Returns nil when no zone system +// is wired (standalone tests). +// --------------------------------------------------------------------------- + +static int luaIsZoneCovered(lua_State *L) +{ + luaL_checktype(L, 1, LUA_TSTRING); + if (!s_zoneCoverageChecker) { + lua_pushnil(L); + return 1; + } + lua_pushboolean(L, + s_zoneCoverageChecker(lua_tostring(L, 1)) ? 1 : 0); + return 1; +} + // --------------------------------------------------------------------------- // Register all entity API functions // --------------------------------------------------------------------------- @@ -242,6 +383,18 @@ void registerLuaEntityApi(lua_State *L) lua_pushcfunction(L, luaGetChildren); lua_setfield(L, -2, "children"); + lua_pushcfunction(L, luaGetPlayerCharacter); + lua_setfield(L, -2, "get_player_character"); + + lua_pushcfunction(L, luaGetEntityPosition); + lua_setfield(L, -2, "get_entity_position"); + + lua_pushcfunction(L, luaTeleportEntity); + lua_setfield(L, -2, "teleport_entity"); + + lua_pushcfunction(L, luaIsZoneCovered); + lua_setfield(L, -2, "is_zone_covered"); + // Set the global lua_setglobal(L, "ecs"); } diff --git a/src/features/editScene/lua/LuaEntityApi.hpp b/src/features/editScene/lua/LuaEntityApi.hpp index e6a72d8..48b7daa 100644 --- a/src/features/editScene/lua/LuaEntityApi.hpp +++ b/src/features/editScene/lua/LuaEntityApi.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include /** @@ -25,6 +26,10 @@ * ecs.get_entity_name(id) -> string or nil * ecs.parent(id) -> int (parent ID) or nil * ecs.children(id) -> table of child IDs + * ecs.get_player_character() -> int (entity ID) or nil + * ecs.get_entity_position(id) -> {x,y,z} (render space) or nil + * ecs.teleport_entity(id, x, y, z [, yawDeg]) -> bool + * ecs.is_zone_covered(zoneId) -> bool or nil (no zone system) */ namespace editScene @@ -99,6 +104,55 @@ struct LuaEntityIdMap { /** @brief Global entity ID map instance. */ extern LuaEntityIdMap g_luaEntityIdMap; +/** + * @brief Set the resolver used by get_player_character(). + * + * Called by EditorApp during setup. Kept as a plain callback (same + * pattern as LuaHudApi) so this translation unit does not depend on + * EditorApp.hpp — standalone Lua test targets compile this file against + * the stub Ogre.h in tests/, without the real OGRE/Jolt headers. + */ +void setLuaPlayerCharacterResolver( + const std::function &resolver); + +/** + * @brief Set the position getter used by get_entity_position(). + * + * The callback fills `outPos` with the entity's render-space position + * (derived node position when available) and returns true; it returns + * false when the entity has no transform. Injected by EditorApp so + * this file does not need Transform.hpp (unavailable in the stub-OGRE + * test targets). + */ +void setLuaEntityPositionGetter( + const std::function + &getter); + +/** + * @brief Set the teleporter used by teleport_entity(). + * + * The callback receives the entity, a render-space position, a yaw flag + * and a yaw in degrees; it returns true when the entity was moved. The + * implementation (in EditorApp.cpp) teleports a live rigid body + * world-space with zeroed velocities when present and falls back to + * moving the TransformComponent directly otherwise. + */ +void setLuaEntityTeleporter( + const std::function &teleporter); + +/** + * @brief Set the coverage checker used by is_zone_covered(). + * + * The callback reports whether the named TargetZoneComponent zone + * currently has at least one resting occupant (ZoneSystem state — the + * same state that drives the zone's covered material). Injected by + * EditorApp; when unset, is_zone_covered() returns nil and Lua-side + * trackers fall back to counting zone_entered/zone_left events. + */ +void setLuaZoneCoverageChecker( + const std::function &checker); + /** * @brief Convert a Flecs entity to a Lua integer ID. * @return The integer ID, or -1 if the entity is invalid. diff --git a/src/features/editScene/lua/LuaHudApi.cpp b/src/features/editScene/lua/LuaHudApi.cpp new file mode 100644 index 0000000..c1c0189 --- /dev/null +++ b/src/features/editScene/lua/LuaHudApi.cpp @@ -0,0 +1,60 @@ +#include "LuaHudApi.hpp" +#include "../systems/GameHudSystem.hpp" + +namespace editScene +{ + +static GameHudSystem *s_hud = nullptr; + +void setLuaHudSystem(GameHudSystem *hud) +{ + s_hud = hud; +} + +static int luaHudSetStatus(lua_State *L) +{ + const char *key = luaL_checkstring(L, 1); + const char *text = luaL_checkstring(L, 2); + if (s_hud) + s_hud->setStatus(key, text); + return 0; +} + +static int luaHudClearStatus(lua_State *L) +{ + const char *key = luaL_checkstring(L, 1); + if (s_hud) + s_hud->clearStatus(key); + return 0; +} + +static int luaHudPushMessage(lua_State *L) +{ + const char *text = luaL_checkstring(L, 1); + float ttl = (float)luaL_optnumber(L, 2, 6.0); + if (s_hud) + s_hud->pushMessage(text, ttl); + return 0; +} + +void registerLuaHudApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_newtable(L); + lua_pushcfunction(L, luaHudSetStatus); + lua_setfield(L, -2, "set_status"); + lua_pushcfunction(L, luaHudClearStatus); + lua_setfield(L, -2, "clear_status"); + lua_pushcfunction(L, luaHudPushMessage); + lua_setfield(L, -2, "push_message"); + lua_setfield(L, -2, "hud"); + + lua_pop(L, 1); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaHudApi.hpp b/src/features/editScene/lua/LuaHudApi.hpp new file mode 100644 index 0000000..8570c44 --- /dev/null +++ b/src/features/editScene/lua/LuaHudApi.hpp @@ -0,0 +1,31 @@ +#ifndef EDITSCENE_LUA_HUD_API_HPP +#define EDITSCENE_LUA_HUD_API_HPP +#pragma once + +#include + +class GameHudSystem; + +namespace editScene +{ + +/** + * @brief Register the game HUD Lua API (F12). + * + * Adds the "ecs.hud" table: + * ecs.hud.set_status(key, text) -- persistent status line + * ecs.hud.clear_status(key) -- remove a status line + * ecs.hud.push_message(text [, ttl]) -- transient banner (seconds) + * + * The calls forward to the GameHudSystem instance set with + * setLuaHudSystem() (EditorApp does this at startup); they are safe + * no-ops when no HUD exists (e.g. unit tests). + * + * @param L The Lua state (the "ecs" table must already exist). + */ +void registerLuaHudApi(lua_State *L); +void setLuaHudSystem(GameHudSystem *hud); + +} // namespace editScene + +#endif // EDITSCENE_LUA_HUD_API_HPP diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index 25082c4..3bcdff9 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -1153,6 +1153,11 @@ public: JPH::MassProperties msp; msp.ScaleToMass(mass); bodySettings.mMassPropertiesOverride = msp; + /* Without this Jolt ignores mMassPropertiesOverride + * and derives mass from the shape density (1000 kg/m3), + * so rigidBody.mass had no effect at all. */ + bodySettings.mOverrideMassProperties = + JPH::EOverrideMassProperties::CalculateInertia; } JPH::BodyID id = createBody(bodySettings, listener); if (shape->GetType() == JPH::EShapeType::HeightField) { @@ -1788,6 +1793,13 @@ public: physics_system.GetBodyInterface().GetAngularVelocity( id)); } + void setVelocities(JPH::BodyID id, const Ogre::Vector3 &linear, + const Ogre::Vector3 &angular) + { + physics_system.GetBodyInterface().SetLinearAndAngularVelocity( + id, JPH::Vec3(linear.x, linear.y, linear.z), + JPH::Vec3(angular.x, angular.y, angular.z)); + } float getFriction(JPH::BodyID id) { return physics_system.GetBodyInterface().GetFriction(id); @@ -2207,6 +2219,12 @@ Ogre::Vector3 JoltPhysicsWrapper::getAngularVelocity(JPH::BodyID id) { return phys->getAngularVelocity(id); } +void JoltPhysicsWrapper::setVelocities(JPH::BodyID id, + const Ogre::Vector3 &linear, + const Ogre::Vector3 &angular) +{ + phys->setVelocities(id, linear, angular); +} float JoltPhysicsWrapper::getFriction(JPH::BodyID id) { return phys->getFriction(id); diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index dc4ca7f..b764f04 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -232,6 +232,8 @@ public: bool activate = true); Ogre::Vector3 getLinearVelocity(JPH::BodyID id); Ogre::Vector3 getAngularVelocity(JPH::BodyID id); + void setVelocities(JPH::BodyID id, const Ogre::Vector3 &linear, + const Ogre::Vector3 &angular); float getFriction(JPH::BodyID id); void setFriction(JPH::BodyID id, float friction); float getGravityFactor(JPH::BodyID id); diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index cceee70..8bd67f0 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -288,6 +288,22 @@ void ActuatorSystem::update(float deltaTime) return; } + /* While the player is driving a vehicle, E means "exit the vehicle" + * (handled by VehicleControllerSystem), not "use actuator": skip + * prompt collection entirely. */ + bool playerDriving = false; + m_world.query().each( + [&](flecs::entity, PlayerControllerComponent &pc) { + if (pc.driving) + playerDriving = true; + }); + if (playerDriving) { + m_visibleActuators.clear(); + m_targetIndex = -1; + m_labelText.clear(); + return; + } + /* Targeting/prompts project through ImGui::GetMainViewport(); in * headless mode there is no ImGui context at all, so skip the * collection/label/input part (cooldowns above and the executing diff --git a/src/features/editScene/systems/CharacterSystem.hpp b/src/features/editScene/systems/CharacterSystem.hpp index ff4692c..bde358e 100644 --- a/src/features/editScene/systems/CharacterSystem.hpp +++ b/src/features/editScene/systems/CharacterSystem.hpp @@ -43,6 +43,14 @@ public: */ void enablePhysics(flecs::entity e); + /** True when the entity has a live JPH::Character capsule + * (headless spawns may fall back to no capsule). */ + bool hasPhysicsCharacter(flecs::entity e) const + { + auto it = m_states.find(e.id()); + return it != m_states.end() && it->second.character != nullptr; + } + private: struct CharacterState { JPH::Character *character = nullptr; diff --git a/src/features/editScene/systems/GameHudSystem.cpp b/src/features/editScene/systems/GameHudSystem.cpp new file mode 100644 index 0000000..2029cca --- /dev/null +++ b/src/features/editScene/systems/GameHudSystem.cpp @@ -0,0 +1,96 @@ +#include "GameHudSystem.hpp" +#include "../EditorApp.hpp" +#include + +GameHudSystem::GameHudSystem(EditorApp *editorApp) + : m_editorApp(editorApp) +{ +} + +void GameHudSystem::setStatus(const Ogre::String &key, + const Ogre::String &text) +{ + if (text.empty()) + m_statusLines.erase(key); + else + m_statusLines[key] = text; +} + +void GameHudSystem::clearStatus(const Ogre::String &key) +{ + m_statusLines.erase(key); +} + +Ogre::String GameHudSystem::getStatus(const Ogre::String &key) const +{ + auto it = m_statusLines.find(key); + return it != m_statusLines.end() ? it->second : Ogre::String(); +} + +void GameHudSystem::pushMessage(const Ogre::String &text, float ttlSeconds) +{ + if (text.empty()) + return; + m_messages.push_back({ text, ttlSeconds }); +} + +void GameHudSystem::update(float deltaTime) +{ + for (auto it = m_messages.begin(); it != m_messages.end();) { + it->remaining -= deltaTime; + if (it->remaining <= 0.0f) + it = m_messages.erase(it); + else + ++it; + } +} + +void GameHudSystem::render() +{ + /* Game mode while playing only. */ + if (!m_editorApp || + m_editorApp->getGameMode() != EditorApp::GameMode::Game || + m_editorApp->getGamePlayState() != + EditorApp::GamePlayState::Playing) + return; + + /* Headless runs have no ImGui context. */ + if (!ImGui::GetCurrentContext()) + return; + + if (m_statusLines.empty() && m_messages.empty()) + return; + + ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(viewport->WorkPos.x + + viewport->WorkSize.x - 10.0f, + viewport->WorkPos.y + 10.0f), + ImGuiCond_Always, ImVec2(1.0f, 0.0f)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav; + + if (!ImGui::Begin("##game_hud", nullptr, flags)) + return; + + for (const auto &line : m_statusLines) + ImGui::Text("%s", line.second.c_str()); + + if (!m_statusLines.empty() && !m_messages.empty()) + ImGui::Separator(); + + for (const Message &msg : m_messages) { + /* Fade out over the last second. */ + float alpha = msg.remaining < 1.0f ? msg.remaining : 1.0f; + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(1.0f, 0.9f, 0.4f, alpha)); + ImGui::Text("%s", msg.text.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::End(); +} diff --git a/src/features/editScene/systems/GameHudSystem.hpp b/src/features/editScene/systems/GameHudSystem.hpp new file mode 100644 index 0000000..ca2cec8 --- /dev/null +++ b/src/features/editScene/systems/GameHudSystem.hpp @@ -0,0 +1,59 @@ +#ifndef EDITSCENE_GAMEHUDSYSTEM_HPP +#define EDITSCENE_GAMEHUDSYSTEM_HPP +#pragma once + +#include +#include +#include + +class EditorApp; + +/** + * Game HUD overlay (F12, see demos/demo-sokoban/PLAN.md Phase 7). + * + * Game-mode-only ImGui overlay in the top-right corner (no window + * chrome), driven mostly from Lua through the ecs.hud.* bindings: + * + * - setStatus(key, text) / clearStatus(key): persistent status lines + * (e.g. "Sokoban: crates 2/3"), ordered by key; + * - pushMessage(text, ttl): transient banner messages (e.g. "Quest + * completed: Crate Yard") that expire after ttl seconds. + * + * Wire-in: ImGuiRenderListener calls update(deltaTime) + render() next + * to ActuatorSystem::render(); render() itself gates on game mode + + * Playing and on a live ImGui context (headless runs skip it). + */ +class GameHudSystem { +public: + GameHudSystem(EditorApp *editorApp); + + void update(float deltaTime); + void render(); + + void setStatus(const Ogre::String &key, const Ogre::String &text); + void clearStatus(const Ogre::String &key); + void pushMessage(const Ogre::String &text, float ttlSeconds = 6.0f); + + /* Test/inspection API. */ + Ogre::String getStatus(const Ogre::String &key) const; + int getStatusCount() const + { + return (int)m_statusLines.size(); + } + int getMessageCount() const + { + return (int)m_messages.size(); + } + +private: + struct Message { + Ogre::String text; + float remaining; + }; + + EditorApp *m_editorApp; + std::map m_statusLines; + std::vector m_messages; +}; + +#endif // EDITSCENE_GAMEHUDSYSTEM_HPP diff --git a/src/features/editScene/systems/PlayerControllerSystem.cpp b/src/features/editScene/systems/PlayerControllerSystem.cpp index 7d162ae..150d788 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.cpp +++ b/src/features/editScene/systems/PlayerControllerSystem.cpp @@ -249,11 +249,16 @@ void PlayerControllerSystem::update(float deltaTime) Ogre::Vector3 charPos = transform.node->_getDerivedPosition(); - // Update camera - if (pc.cameraMode == PlayerControllerComponent::TPS) { - updateTPSCamera(pc, state, charPos, deltaTime); - } else { - updateFPSCamera(pc, state, charPos); + // Update camera. While driving this is deferred to + // postPhysicsUpdate(): the seat pin there runs after the + // physics step syncs the vehicle node, and the camera must + // read the freshly pinned character position. + if (!pc.driving) { + if (pc.cameraMode == PlayerControllerComponent::TPS) { + updateTPSCamera(pc, state, charPos, deltaTime); + } else { + updateFPSCamera(pc, state, charPos); + } } // Update locomotion @@ -261,6 +266,40 @@ void PlayerControllerSystem::update(float deltaTime) }); } +void PlayerControllerSystem::postPhysicsUpdate(float deltaTime) +{ + if (!m_editorApp || + m_editorApp->getGameMode() != EditorApp::GameMode::Game || + m_editorApp->getGamePlayState() != + EditorApp::GamePlayState::Playing) + return; + + m_world.query().each( + [&](flecs::entity e, PlayerControllerComponent &pc) { + if (!pc.driving) + return; + auto it = m_states.find(e.id()); + if (it == m_states.end()) + return; + ControllerState &state = it->second; + if (!state.targetEntity.is_alive() || + !state.targetEntity.has()) + return; + auto &transform = + state.targetEntity.get(); + if (!transform.node) + return; + + Ogre::Vector3 charPos = + transform.node->_getDerivedPosition(); + if (pc.cameraMode == PlayerControllerComponent::TPS) { + updateTPSCamera(pc, state, charPos, deltaTime); + } else { + updateFPSCamera(pc, state, charPos); + } + }); +} + void PlayerControllerSystem::updateTPSCamera(PlayerControllerComponent &pc, ControllerState &state, const Ogre::Vector3 &charPos, @@ -479,8 +518,9 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc, if (!state.targetEntity.has()) return; - // Skip locomotion if input is locked by an executing action - if (pc.inputLocked) + // Skip locomotion if input is locked by an executing action or the + // player is driving a vehicle (VehicleControllerSystem owns WASD). + if (pc.inputLocked || pc.driving) return; GameInputState &input = m_editorApp->getGameInputState(); diff --git a/src/features/editScene/systems/PlayerControllerSystem.hpp b/src/features/editScene/systems/PlayerControllerSystem.hpp index 02257be..418c207 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.hpp +++ b/src/features/editScene/systems/PlayerControllerSystem.hpp @@ -27,6 +27,15 @@ public: void update(float deltaTime); + /** + * Driving-mode camera update. Runs after the physics step and + * VehicleControllerSystem's seat pin so the camera pivot reads the + * CURRENT frame's pinned character position; the per-frame update() + * skips the camera for driving controllers. No-op for on-foot + * controllers. + */ + void postPhysicsUpdate(float deltaTime); + /** * Return the live target entity for a controller. If the controller has * already been initialized this frame, returns its cached target. Otherwise diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 70aaaf0..9151a19 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -47,6 +47,8 @@ #include "../components/ActionDebug.hpp" #include "../components/SmartObject.hpp" #include "../components/Actuator.hpp" +#include "../components/Pushable.hpp" +#include "../components/TargetZone.hpp" #include "../components/GoapPlanner.hpp" #include "../components/Item.hpp" #include "../components/Inventory.hpp" @@ -410,6 +412,12 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["actuator"] = serializeActuator(entity); } + if (entity.has()) { + json["pushable"] = serializePushable(entity); + } + if (entity.has()) { + json["targetZone"] = serializeTargetZone(entity); + } if (entity.has()) { json["eventHandler"] = serializeEventHandler(entity); } @@ -659,6 +667,12 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("actuator")) { deserializeActuator(entity, json["actuator"]); } + if (json.contains("pushable")) { + deserializePushable(entity, json["pushable"]); + } + if (json.contains("targetZone")) { + deserializeTargetZone(entity, json["targetZone"]); + } if (json.contains("eventHandler")) { deserializeEventHandler(entity, json["eventHandler"]); } @@ -984,6 +998,12 @@ void SceneSerializer::deserializeEntityComponents( deserializeEventHandler(entity, json["eventHandler"]); } } + if (json.contains("pushable")) { + deserializePushable(entity, json["pushable"]); + } + if (json.contains("targetZone")) { + deserializeTargetZone(entity, json["targetZone"]); + } if (json.contains("sceneScript")) { deserializeSceneScript(entity, json["sceneScript"]); } @@ -1653,6 +1673,23 @@ nlohmann::json SceneSerializer::serializeVehicle(flecs::entity entity) { "y", vehicle.seatOffset.y }, { "z", vehicle.seatOffset.z } }; json["wheelMeshName"] = vehicle.wheelMeshName; + json["steeringWheelMeshName"] = vehicle.steeringWheelMeshName; + json["steeringWheelOffset"] = { + { "x", vehicle.steeringWheelOffset.x }, + { "y", vehicle.steeringWheelOffset.y }, + { "z", vehicle.steeringWheelOffset.z } }; + json["steeringWheelTiltDeg"] = vehicle.steeringWheelTiltDeg; + json["steeringRatio"] = vehicle.steeringRatio; + json["cameraDistance"] = vehicle.cameraDistance; + json["cameraHeight"] = vehicle.cameraHeight; + json["rearSteer"] = vehicle.rearSteer; + json["engineBrake"] = vehicle.engineBrake; + json["forkMeshName"] = vehicle.forkMeshName; + json["forkOffset"] = { { "x", vehicle.forkOffset.x }, + { "y", vehicle.forkOffset.y }, + { "z", vehicle.forkOffset.z } }; + json["forkMaxHeight"] = vehicle.forkMaxHeight; + json["forkSpeed"] = vehicle.forkSpeed; nlohmann::json wheels = nlohmann::json::array(); for (const VehicleWheel &w : vehicle.wheels) { @@ -1691,6 +1728,30 @@ void SceneSerializer::deserializeVehicle(flecs::entity entity, so.value("z", 0.0f)); } vehicle.wheelMeshName = json.value("wheelMeshName", ""); + vehicle.steeringWheelMeshName = + json.value("steeringWheelMeshName", ""); + if (json.contains("steeringWheelOffset")) { + auto &so = json["steeringWheelOffset"]; + vehicle.steeringWheelOffset = + Ogre::Vector3(so.value("x", 0.0f), so.value("y", 0.0f), + so.value("z", 0.0f)); + } + vehicle.steeringWheelTiltDeg = + json.value("steeringWheelTiltDeg", 25.0f); + vehicle.steeringRatio = json.value("steeringRatio", 4.0f); + vehicle.cameraDistance = json.value("cameraDistance", 5.0f); + vehicle.cameraHeight = json.value("cameraHeight", 2.2f); + vehicle.rearSteer = json.value("rearSteer", false); + vehicle.engineBrake = json.value("engineBrake", 0.5f); + vehicle.forkMeshName = json.value("forkMeshName", ""); + if (json.contains("forkOffset")) { + auto &fo = json["forkOffset"]; + vehicle.forkOffset = Ogre::Vector3(fo.value("x", 0.0f), + fo.value("y", 0.0f), + fo.value("z", 0.0f)); + } + vehicle.forkMaxHeight = json.value("forkMaxHeight", 1.2f); + vehicle.forkSpeed = json.value("forkSpeed", 0.8f); if (json.contains("wheels")) { for (const auto &wj : json["wheels"]) { @@ -3400,6 +3461,7 @@ nlohmann::json SceneSerializer::serializePlayerController(flecs::entity entity) json["swimIdleState"] = pc.swimIdleState; json["swimState"] = pc.swimState; json["swimFastState"] = pc.swimFastState; + json["drivingState"] = pc.drivingState; json["actuatorDistance"] = pc.actuatorDistance; json["actuatorCooldown"] = pc.actuatorCooldown; json["actuatorColor"] = { pc.actuatorColor.x, pc.actuatorColor.y, @@ -3443,6 +3505,7 @@ void SceneSerializer::deserializePlayerController(flecs::entity entity, pc.swimIdleState = json.value("swimIdleState", pc.swimIdleState); pc.swimState = json.value("swimState", pc.swimState); pc.swimFastState = json.value("swimFastState", pc.swimFastState); + pc.drivingState = json.value("drivingState", pc.drivingState); pc.actuatorDistance = json.value("actuatorDistance", pc.actuatorDistance); pc.actuatorCooldown = @@ -4331,6 +4394,53 @@ void SceneSerializer::deserializeActuator(flecs::entity entity, entity.set(actuator); } +nlohmann::json SceneSerializer::serializePushable(flecs::entity entity) +{ + const PushableComponent &pushable = entity.get(); + nlohmann::json json; + json["enabled"] = pushable.enabled; + return json; +} + +void SceneSerializer::deserializePushable(flecs::entity entity, + const nlohmann::json &json) +{ + PushableComponent pushable; + pushable.enabled = json.value("enabled", true); + entity.set(pushable); +} + +nlohmann::json SceneSerializer::serializeTargetZone(flecs::entity entity) +{ + const TargetZoneComponent &zone = entity.get(); + nlohmann::json json; + json["zoneId"] = zone.zoneId; + json["halfExtents"] = { { "x", zone.halfExtents.x }, + { "y", zone.halfExtents.y }, + { "z", zone.halfExtents.z } }; + json["uncoveredMaterialName"] = zone.uncoveredMaterialName; + json["coveredMaterialName"] = zone.coveredMaterialName; + json["enabled"] = zone.enabled; + return json; +} + +void SceneSerializer::deserializeTargetZone(flecs::entity entity, + const nlohmann::json &json) +{ + TargetZoneComponent zone; + zone.zoneId = json.value("zoneId", ""); + if (json.contains("halfExtents")) { + const auto &h = json["halfExtents"]; + zone.halfExtents = Ogre::Vector3(h.value("x", 0.6f), + h.value("y", 0.1f), + h.value("z", 0.6f)); + } + zone.uncoveredMaterialName = json.value("uncoveredMaterialName", ""); + zone.coveredMaterialName = json.value("coveredMaterialName", ""); + zone.enabled = json.value("enabled", true); + entity.set(zone); +} + nlohmann::json SceneSerializer::serializeEventHandler(flecs::entity entity) { const EventHandlerComponent &handler = diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 9a4f6eb..9c7c861 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -279,6 +279,8 @@ private: nlohmann::json serializePathFollowing(flecs::entity entity); nlohmann::json serializeSmartObject(flecs::entity entity); nlohmann::json serializeActuator(flecs::entity entity); + nlohmann::json serializePushable(flecs::entity entity); + nlohmann::json serializeTargetZone(flecs::entity entity); nlohmann::json serializeEventHandler(flecs::entity entity); nlohmann::json serializeSceneScript(flecs::entity entity); nlohmann::json serializeStandaloneDoor(flecs::entity entity); @@ -294,6 +296,10 @@ private: const nlohmann::json &json); void deserializeActuator(flecs::entity entity, const nlohmann::json &json); + void deserializePushable(flecs::entity entity, + const nlohmann::json &json); + void deserializeTargetZone(flecs::entity entity, + const nlohmann::json &json); void deserializeEventHandler(flecs::entity entity, const nlohmann::json &json); void deserializeSceneScript(flecs::entity entity, diff --git a/src/features/editScene/systems/VehicleControllerSystem.cpp b/src/features/editScene/systems/VehicleControllerSystem.cpp new file mode 100644 index 0000000..e283a8a --- /dev/null +++ b/src/features/editScene/systems/VehicleControllerSystem.cpp @@ -0,0 +1,413 @@ +#include "VehicleControllerSystem.hpp" +#include "../EditorApp.hpp" +#include "../components/EntityName.hpp" +#include "../components/PlayerController.hpp" +#include "../components/RigidBody.hpp" +#include "../components/Transform.hpp" +#include "../components/Vehicle.hpp" +#include "../physics/physics.h" +#include "AnimationTreeSystem.hpp" +#include "CharacterSystem.hpp" +#include "GameHudSystem.hpp" +#include + +namespace +{ +/* Lateral exit clearance: chassis half width (0.7 for the demo + * forklift) plus the character capsule radius with margin. */ +const float EXIT_SIDE_OFFSET = 1.6f; +const float EXIT_END_OFFSET = 2.4f; +/* Exit probe heights: horizontal clearance ray and the down ray that + * finds the ground. */ +const float EXIT_PROBE_HEIGHT = 1.0f; +const float EXIT_GROUND_PROBE_UP = 3.0f; +const float EXIT_GROUND_PROBE_DOWN = 2.0f; +/* Exit debounce: the entering E tap must not count as an exit press, and + * exiting needs a deliberately longer hold than entering. */ +const float EXIT_MIN_SEAT_TIME = 0.75f; +const float EXIT_HOLD_TIME = 0.5f; +} + +VehicleControllerSystem::VehicleControllerSystem(flecs::world &world, + EditorApp *editorApp) + : m_world(world) + , m_editorApp(editorApp) +{ + m_enterListener = EventBus::getInstance().subscribe( + "vehicle_enter", + [this](const Ogre::String &eventName, + const editScene::EventParams ¶ms) { + onVehicleEnterEvent(eventName, params); + }); +} + +VehicleControllerSystem::~VehicleControllerSystem() +{ + EventBus::getInstance().unsubscribe(m_enterListener); +} + +flecs::entity VehicleControllerSystem::getDrivenVehicle() const +{ + if (!m_driving) + return flecs::entity::null(); + return m_world.entity(m_vehicleId); +} + +flecs::entity VehicleControllerSystem::findPlayerController() +{ + flecs::entity result = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, EntityNameComponent &name, + PlayerControllerComponent &) { + if (result.is_alive()) + return; + if (name.name == "player") + result = e; + }); + return result; +} + +void VehicleControllerSystem::onVehicleEnterEvent( + const Ogre::String &eventName, const editScene::EventParams ¶ms) +{ + (void)eventName; + if (m_driving) + return; + + Ogre::String vehicleName = params.getString("vehicle", ""); + if (vehicleName.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "[VehicleControllerSystem] vehicle_enter event without " + "a 'vehicle' param, ignored"); + return; + } + + flecs::entity vehicle = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, EntityNameComponent &name) { + if (vehicle.is_alive()) + return; + if (name.name == vehicleName) + vehicle = e; + }); + if (!vehicle.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "[VehicleControllerSystem] vehicle_enter: no entity " + "named '" + + vehicleName + "'"); + return; + } + enterVehicle(vehicle); +} + +bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle) +{ + if (m_driving) + return false; + if (!m_editorApp || !vehicle.is_alive() || + !vehicle.has() || + !vehicle.has()) + return false; + + const TransformComponent &vt = vehicle.get(); + if (!vt.node) + return false; + + flecs::entity character = m_editorApp->getPlayerCharacterEntity(); + if (!character.is_alive() || + !character.has()) + return false; + const TransformComponent &ct = character.get(); + if (!ct.node) + return false; + + flecs::entity controller = findPlayerController(); + if (!controller.is_alive()) + return false; + auto &pc = controller.get_mut(); + + m_savedTpsDistance = pc.tpsDistance; + m_savedTpsHeight = pc.tpsHeight; + pc.driving = true; + pc.tpsDistance = vehicle.get().cameraDistance; + pc.tpsHeight = vehicle.get().cameraHeight; + + CharacterSystem *cs = m_editorApp->getCharacterSystem(); + if (cs) + cs->disablePhysics(character); + + /* Switch to the driving animation state (currently mapped to the + * "sitting" animation in the locomotion state machine). */ + AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem(); + if (ats) + ats->setState(character, pc.locomotionStateMachine, + pc.drivingState); + + vehicle.add(); + + m_driving = true; + m_vehicleId = vehicle.id(); + m_characterId = character.id(); + m_controllerId = controller.id(); + m_seatTime = 0.0f; + m_exitHold = 0.0f; + + pinCharacterToSeat(vehicle, character); + + /* Controls hint on the game HUD (fork line only when the vehicle + * actually has a fork). */ + GameHudSystem *hud = m_editorApp->getGameHudSystem(); + if (hud) { + Ogre::String hint = + "Driving: W/S move, A/D steer, hold E to exit"; + if (!vehicle.get().forkMeshName.empty()) + hint = "Driving: W/S move, A/D steer, R/F fork " + "up/down, hold E to exit"; + hud->pushMessage(hint, 10.0f); + } + + Ogre::LogManager::getSingleton().logMessage( + "[VehicleControllerSystem] entered vehicle"); + return true; +} + +void VehicleControllerSystem::pinCharacterToSeat(flecs::entity vehicle, + flecs::entity character) +{ + const TransformComponent &vt = vehicle.get(); + const VehicleComponent &vc = vehicle.get(); + auto &ct = character.get_mut(); + if (!vt.node || !ct.node) + return; + + /* Scene nodes are root children (TransformComponent::applyToNode + * uses setPosition), so derived == world here; vehicle scale is + * 1 in practice. */ + Ogre::Vector3 seat = vt.node->_getDerivedOrientation() * + vc.seatOffset + + vt.node->_getDerivedPosition(); + ct.node->setPosition(seat); + ct.node->setOrientation(vt.node->_getDerivedOrientation()); + ct.position = seat; +} + +void VehicleControllerSystem::update(float deltaTime) +{ + if (!m_driving) + return; + + flecs::entity vehicle = m_world.entity(m_vehicleId); + flecs::entity character = m_world.entity(m_characterId); + + /* The scene may have been switched/torn down under us: drop the + * state (the per-entity restore already happened or the entity is + * gone). */ + if (!vehicle.is_alive() || !vehicle.has()) { + if (character.is_alive()) + restoreOnFoot(); + m_driving = false; + m_vehicleId = 0; + m_characterId = 0; + m_controllerId = 0; + return; + } + if (!character.is_alive()) { + vehicle.remove(); + m_driving = false; + m_vehicleId = 0; + m_characterId = 0; + m_controllerId = 0; + return; + } + + GameInputState &input = m_editorApp->getGameInputState(); + auto &vc = vehicle.get_mut(); + vc.inputForward = input.w ? 1.0f : (input.s ? -1.0f : 0.0f); + vc.inputRight = input.d ? 1.0f : (input.a ? -1.0f : 0.0f); + vc.inputBrake = 0.0f; + vc.inputHandBrake = input.space ? 1.0f : 0.0f; + /* Fork lift: R raises, F lowers (hold). */ + vc.inputFork = input.r ? 1.0f : (input.f ? -1.0f : 0.0f); + + /* The seat pin runs in postPhysicsUpdate() (after the physics + * step syncs the vehicle node) so the driver and the seat are + * always rendered from the same physics state. */ + + /* Hold-to-exit: a short cooldown after seating must pass first (so + * the entering E tap never counts), then E must be held for + * EXIT_HOLD_TIME. */ + m_seatTime += deltaTime; + if (m_seatTime >= EXIT_MIN_SEAT_TIME && input.e) + m_exitHold += deltaTime; + else + m_exitHold = 0.0f; + if (m_exitHold >= EXIT_HOLD_TIME) + exitVehicle(); +} + +void VehicleControllerSystem::postPhysicsUpdate() +{ + if (!m_driving) + return; + + flecs::entity vehicle = m_world.entity(m_vehicleId); + flecs::entity character = m_world.entity(m_characterId); + if (!vehicle.is_alive() || !vehicle.has() || + !vehicle.has() || !character.is_alive() || + !character.has()) + return; + + pinCharacterToSeat(vehicle, character); +} + +bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle, + Ogre::Vector3 &out) const +{ + const TransformComponent &vt = vehicle.get(); + if (!vt.node) + return false; + + Ogre::Vector3 vpos = vt.node->_getDerivedPosition(); + Ogre::Quaternion vor = vt.node->_getDerivedOrientation(); + Ogre::Vector3 right = vor * Ogre::Vector3::UNIT_X; + Ogre::Vector3 fwd = vor * Ogre::Vector3::UNIT_Z; + + const Ogre::Vector3 candidates[4] = { + vpos + right * EXIT_SIDE_OFFSET, + vpos - right * EXIT_SIDE_OFFSET, + vpos - fwd * EXIT_END_OFFSET, + vpos + fwd * EXIT_END_OFFSET, + }; + + JoltPhysicsWrapper *physics = JoltPhysicsWrapper::getSingletonPtr(); + for (int i = 0; i < 4; i++) { + Ogre::Vector3 spot = candidates[i]; + if (!physics) { + out = spot; + return true; + } + /* raycastQuery only sees Layers::NON_MOVING, so the + * vehicle chassis (MOVING) never blocks its own exit. */ + Ogre::Vector3 hitPos; + JPH::BodyID hitBody; + Ogre::Vector3 eye = vpos + + Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); + Ogre::Vector3 probe = spot + + Ogre::Vector3(0, EXIT_PROBE_HEIGHT, 0); + if (physics->raycastQuery(eye, probe, hitPos, hitBody)) + continue; /* wall/terrain between vehicle and spot */ + Ogre::Vector3 top = spot + + Ogre::Vector3(0, EXIT_GROUND_PROBE_UP, 0); + Ogre::Vector3 bottom = + spot - Ogre::Vector3(0, EXIT_GROUND_PROBE_DOWN, 0); + if (!physics->raycastQuery(top, bottom, hitPos, hitBody)) + continue; /* no ground under the spot */ + out = hitPos + Ogre::Vector3(0, 0.05f, 0); + return true; + } + return false; +} + +void VehicleControllerSystem::restoreOnFoot() +{ + flecs::entity character = m_world.entity(m_characterId); + flecs::entity controller = m_world.entity(m_controllerId); + + if (character.is_alive()) { + CharacterSystem *cs = m_editorApp->getCharacterSystem(); + if (cs) + cs->enablePhysics(character); + } + + if (controller.is_alive() && + controller.has()) { + auto &pc = controller.get_mut(); + pc.driving = false; + pc.tpsDistance = m_savedTpsDistance; + pc.tpsHeight = m_savedTpsHeight; + + /* Back to the on-foot idle animation. */ + if (character.is_alive()) { + AnimationTreeSystem *ats = + m_editorApp->getAnimationTreeSystem(); + if (ats) + ats->setState(character, + pc.locomotionStateMachine, + pc.idleState); + } + } +} + +void VehicleControllerSystem::exitVehicle() +{ + if (!m_driving) + return; + + flecs::entity vehicle = m_world.entity(m_vehicleId); + flecs::entity character = m_world.entity(m_characterId); + if (!vehicle.is_alive() || !character.is_alive()) { + resetDriving(); + return; + } + + Ogre::Vector3 spot; + if (!findExitSpot(vehicle, spot)) { + Ogre::LogManager::getSingleton().logMessage( + "[VehicleControllerSystem] no clear exit spot, " + "staying seated"); + return; + } + + /* Move the character node to the exit spot BEFORE re-enabling + * physics: CharacterSystem::update teleports the physics capsule + * to the node position on the next frame. */ + auto &ct = character.get_mut(); + if (ct.node) + ct.node->setPosition(spot); + ct.position = spot; + + /* Zero the driver inputs so the vehicle rolls to a stop. */ + auto &vc = vehicle.get_mut(); + vc.inputForward = 0.0f; + vc.inputRight = 0.0f; + vc.inputBrake = 0.0f; + vc.inputHandBrake = 0.0f; + vc.inputFork = 0.0f; + + vehicle.remove(); + restoreOnFoot(); + + m_driving = false; + m_vehicleId = 0; + m_characterId = 0; + m_controllerId = 0; + + Ogre::LogManager::getSingleton().logMessage( + "[VehicleControllerSystem] exited vehicle"); +} + +void VehicleControllerSystem::resetDriving() +{ + if (!m_driving) + return; + + flecs::entity vehicle = m_world.entity(m_vehicleId); + if (vehicle.is_alive()) { + if (vehicle.has()) { + auto &vc = vehicle.get_mut(); + vc.inputForward = 0.0f; + vc.inputRight = 0.0f; + vc.inputBrake = 0.0f; + vc.inputHandBrake = 0.0f; + vc.inputFork = 0.0f; + } + vehicle.remove(); + } + + restoreOnFoot(); + + m_driving = false; + m_vehicleId = 0; + m_characterId = 0; + m_controllerId = 0; +} diff --git a/src/features/editScene/systems/VehicleControllerSystem.hpp b/src/features/editScene/systems/VehicleControllerSystem.hpp new file mode 100644 index 0000000..f25700c --- /dev/null +++ b/src/features/editScene/systems/VehicleControllerSystem.hpp @@ -0,0 +1,99 @@ +#ifndef EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP +#define EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP +#pragma once + +#include "EventBus.hpp" +#include +#include + +class EditorApp; + +/** + * Vehicle enter/exit + drive input (game mode only, F10). + * + * Entering: a scene action (typically an actuator on the vehicle entity) + * runs a luaTask that sends the "vehicle_enter" event with a "vehicle" + * param naming the vehicle entity; this system listens for that event and + * seats the player character: the character's physics capsule is disabled + * (CharacterSystem::disablePhysics), its scene node is pinned to the + * vehicle's VehicleComponent::seatOffset every frame (after the physics + * step, so the seat never jitters under the driver — see + * postPhysicsUpdate), and the player controller's TPS boom is swapped for + * the vehicle's cameraDistance / cameraHeight (restored on exit). + * PlayerControllerComponent::driving gates off locomotion and actuator + * prompts while seated. + * + * Driving: W/S feed VehicleComponent::inputForward, A/D inputRight, + * Space inputHandBrake; VehicleSystem consumes them in the physics step. + * + * Exit: holding E (a longer press than entering: a short cooldown after + * seating must pass first, then E must be held for a moment) places the + * character on the first raycast-clear spot beside + * (then behind/ahead of) the vehicle, re-enables its physics and restores + * the on-foot camera. When no spot is clear the player stays seated and + * a warning is logged. + * + * Driving state is runtime-only: it does not survive save/load, and + * resetDriving() (called from EditorApp::destroySceneEntities) drops it + * before scene teardown without placement raycasts. + */ +class VehicleControllerSystem { +public: + VehicleControllerSystem(flecs::world &world, EditorApp *editorApp); + ~VehicleControllerSystem(); + + void update(float deltaTime); + + /** + * Pin the seated character to the seat. Must be called AFTER the + * physics step and the rigid-body -> scene node sync (EditorApp + * calls it right after VehicleSystem::postPhysicsUpdate): the + * physics step advances in fixed 60 Hz bursts, so pinning against + * the pre-step node leaves the driver one sync behind the vehicle + * and the seat visibly jitters back-forth under them. No-op when + * not driving. + */ + void postPhysicsUpdate(); + + bool isDriving() const + { + return m_driving; + } + flecs::entity getDrivenVehicle() const; + + /** Seat the player character on the vehicle. Returns false when + * already driving or the entity is not a usable vehicle. */ + bool enterVehicle(flecs::entity vehicle); + /** Place the character next to the vehicle and restore on-foot + * controls. No-op when not driving. */ + void exitVehicle(); + /** Drop driving state without placement raycasts (scene + * teardown/switch). */ + void resetDriving(); + +private: + void onVehicleEnterEvent(const Ogre::String &eventName, + const editScene::EventParams ¶ms); + flecs::entity findPlayerController(); + void pinCharacterToSeat(flecs::entity vehicle, + flecs::entity character); + bool findExitSpot(flecs::entity vehicle, Ogre::Vector3 &out) const; + void restoreOnFoot(); + + flecs::world &m_world; + EditorApp *m_editorApp; + EventBus::ListenerId m_enterListener = 0; + + bool m_driving = false; + flecs::entity_t m_vehicleId = 0; + flecs::entity_t m_characterId = 0; + flecs::entity_t m_controllerId = 0; + /* Stashed on-foot camera settings, restored on exit. */ + float m_savedTpsDistance = 3.0f; + float m_savedTpsHeight = 2.0f; + /* Exit debounce: time seated and current E hold duration. */ + float m_seatTime = 0.0f; + float m_exitHold = 0.0f; +}; + +#endif // EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP diff --git a/src/features/editScene/systems/VehicleSystem.cpp b/src/features/editScene/systems/VehicleSystem.cpp index 1b52ecc..05f84b0 100644 --- a/src/features/editScene/systems/VehicleSystem.cpp +++ b/src/features/editScene/systems/VehicleSystem.cpp @@ -1,4 +1,5 @@ #include "VehicleSystem.hpp" +#include "../components/Pushable.hpp" #include "../components/RigidBody.hpp" #include "../components/Transform.hpp" #include @@ -17,6 +18,7 @@ VehicleSystem::VehicleSystem(flecs::world &world, JoltPhysicsWrapper *physics) m_world.observer("VehicleCleanup") .event(flecs::OnRemove) .each([this](flecs::entity e, VehicleComponent &v) { + forkDetach(e.id()); destroyConstraint(v); destroyWheelVisuals(e.id()); }); @@ -32,6 +34,7 @@ VehicleSystem::~VehicleSystem() m_query.each( [this](flecs::entity e, VehicleComponent &v, RigidBodyComponent &, TransformComponent &) { + forkDetach(e.id()); destroyConstraint(v); destroyWheelVisuals(e.id()); }); @@ -100,10 +103,149 @@ void VehicleSystem::destroyConstraint(VehicleComponent &vehicle) vehicle.constraintCreated = false; } +void VehicleSystem::updateFork(flecs::entity entity, + VehicleComponent &vehicle, + RigidBodyComponent &rigidBody, float deltaTime) +{ + if (vehicle.forkMeshName.empty()) + return; + flecs::entity_t vehicleId = entity.id(); + + float prevHeight = vehicle.forkHeight; + vehicle.forkHeight += vehicle.inputFork * vehicle.forkSpeed * + deltaTime; + if (vehicle.forkHeight < 0.0f) + vehicle.forkHeight = 0.0f; + if (vehicle.forkHeight > vehicle.forkMaxHeight) + vehicle.forkHeight = vehicle.forkMaxHeight; + + auto it = m_carried.find(vehicleId); + if (it == m_carried.end()) { + /* Attach when the fork starts rising from the bottom. */ + if (vehicle.inputFork > 0.0f && prevHeight <= 0.001f) + tryForkAttach(vehicleId, vehicle, rigidBody); + return; + } + + CarriedCrate &carried = it->second; + flecs::entity crate = m_world.entity(carried.crateId); + if (!crate.is_alive() || !crate.has() || + !crate.get().bodyCreated) { + m_carried.erase(it); + return; + } + + /* Release when the fork is lowered back to the bottom. */ + if (vehicle.inputFork < 0.0f && vehicle.forkHeight <= 0.001f) { + forkDetach(vehicleId); + return; + } + + /* Pin the crate to the carriage: all wrapper positions are + * absolute world coordinates, so the follow math runs in world + * space against the chassis body (the scene node lags a frame). */ + Ogre::Vector3 chassisPos; + Ogre::Quaternion chassisOri; + m_physics->getPositionAndRotation(rigidBody.bodyID, chassisPos, + chassisOri); + Ogre::Vector3 target = chassisPos + + chassisOri * (vehicle.forkOffset + + Ogre::Vector3(0.0f, + vehicle.forkHeight, + 0.0f) + + carried.relOffset); + /* An external teleport (sokoban yard reset, editor move) breaks + * the carry instead of yanking the crate back across the map. */ + Ogre::Vector3 current = m_physics->getPosition(carried.bodyID); + if ((current - target).squaredLength() > 25.0f) { + forkDetach(vehicleId); + return; + } + m_physics->setPositionAndRotation(carried.bodyID, target, + chassisOri * carried.relOri, true); + m_physics->setVelocities(carried.bodyID, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); +} + +void VehicleSystem::tryForkAttach(flecs::entity_t vehicleId, + VehicleComponent &vehicle, + RigidBodyComponent &rigidBody) +{ + Ogre::Vector3 chassisPos; + Ogre::Quaternion chassisOri; + m_physics->getPositionAndRotation(rigidBody.bodyID, chassisPos, + chassisOri); + Ogre::Quaternion invChassis = chassisOri.Inverse(); + + /* Attach box at the tines, absolute chassis space (forkOffset is + * purely visual — the fork mesh renders at forkOffset but the crate + * only needs to clear the chassis collider): the crate center must + * be past the chassis front face (z > 1.4) so the carried crate + * never overlaps the chassis collider. */ + flecs::entity best = flecs::entity::null(); + Ogre::Vector3 bestLocal; + float bestScore = 1e9f; + m_world.query().each( + [&](flecs::entity crate, PushableComponent &pushable, + RigidBodyComponent &crateRb) { + if (!pushable.enabled || !crateRb.bodyCreated || + crate.id() == vehicleId) + return; + Ogre::Vector3 pos = + m_physics->getPosition(crateRb.bodyID); + Ogre::Vector3 rel = invChassis * (pos - chassisPos); + if (fabsf(rel.x) > 0.7f || rel.y < -0.7f || + rel.y > 0.3f || rel.z < 1.4f || rel.z > 2.1f) + return; + float score = fabsf(rel.z - 1.6f) + fabsf(rel.x); + if (score < bestScore) { + bestScore = score; + best = crate; + bestLocal = rel; + } + }); + if (!best.is_alive()) + return; + + CarriedCrate carried; + carried.crateId = best.id(); + carried.bodyID = best.get().bodyID; + carried.relOffset = bestLocal - vehicle.forkOffset; + carried.relOri = invChassis * m_physics->getRotation(carried.bodyID); + carried.savedGravityFactor = + m_physics->getGravityFactor(carried.bodyID); + m_physics->setGravityFactor(carried.bodyID, 0.0f); + m_carried[vehicleId] = carried; + + Ogre::LogManager::getSingleton().logMessage( + "VehicleSystem: crate attached to fork (entity " + + Ogre::StringConverter::toString( + (unsigned long long)vehicleId) + + ")"); +} + +void VehicleSystem::forkDetach(flecs::entity_t vehicleId) +{ + auto it = m_carried.find(vehicleId); + if (it == m_carried.end()) + return; + flecs::entity crate = m_world.entity(it->second.crateId); + if (m_physics && crate.is_alive() && + crate.has() && + crate.get().bodyCreated) { + m_physics->setGravityFactor(it->second.bodyID, + it->second.savedGravityFactor); + m_physics->activate(it->second.bodyID); + } + m_carried.erase(it); +} + void VehicleSystem::createWheelVisuals(flecs::entity entity, VehicleComponent &vehicle) { - if (vehicle.wheelMeshName.empty()) + if (vehicle.wheelMeshName.empty() && + vehicle.steeringWheelMeshName.empty() && + vehicle.forkMeshName.empty()) return; if (!entity.has()) return; @@ -115,6 +257,8 @@ void VehicleSystem::createWheelVisuals(flecs::entity entity, WheelVisual visual; try { for (size_t i = 0; i < vehicle.wheels.size(); i++) { + if (vehicle.wheelMeshName.empty()) + break; Ogre::SceneNode *node = t.node->createChildSceneNode(); Ogre::Entity *ent = t.node->getCreator()->createEntity( vehicle.wheelMeshName); @@ -122,6 +266,33 @@ void VehicleSystem::createWheelVisuals(flecs::entity entity, visual.nodes.push_back(node); visual.entities.push_back(ent); } + if (!vehicle.steeringWheelMeshName.empty()) { + visual.steeringNode = t.node->createChildSceneNode(); + visual.steeringEntity = + t.node->getCreator()->createEntity( + vehicle.steeringWheelMeshName); + visual.steeringNode->attachObject( + visual.steeringEntity); + visual.steeringNode->setPosition( + vehicle.steeringWheelOffset); + /* The mesh's spin axis is local Y; pitch it so the + * wheel plane leans toward the driver by + * steeringWheelTiltDeg from vertical. */ + visual.steeringBaseOri = Ogre::Quaternion( + Ogre::Degree(vehicle.steeringWheelTiltDeg - + 90.0f), + Ogre::Vector3::UNIT_X); + visual.steeringNode->setOrientation( + visual.steeringBaseOri); + } + if (!vehicle.forkMeshName.empty()) { + visual.forkNode = t.node->createChildSceneNode(); + visual.forkEntity = + t.node->getCreator()->createEntity( + vehicle.forkMeshName); + visual.forkNode->attachObject(visual.forkEntity); + visual.forkNode->setPosition(vehicle.forkOffset); + } } catch (const Ogre::Exception &e) { Ogre::LogManager::getSingleton().logMessage( "VehicleSystem: cannot create wheel visuals '" + @@ -141,7 +312,6 @@ void VehicleSystem::destroyWheelVisuals(flecs::entity_t id) void VehicleSystem::prePhysicsUpdate(float deltaTime) { - (void)deltaTime; if (!m_physics) return; @@ -155,6 +325,7 @@ void VehicleSystem::prePhysicsUpdate(float deltaTime) (!rigidBody.bodyCreated || vehicle.constraint->GetVehicleBody()->GetID() != rigidBody.bodyID)) { + forkDetach(entity.id()); destroyConstraint(vehicle); } @@ -188,9 +359,38 @@ void VehicleSystem::prePhysicsUpdate(float deltaTime) if (handbrake != 0.0f) forward = 0.0f; + /* Engine braking: no driver input and still rolling -> mild + * brake, so the heavy chassis does not coast forever. + * Below the threshold all inputs stay 0 and the body can + * sleep. */ + if (forward == 0.0f && brake == 0.0f && handbrake == 0.0f && + vehicle.engineBrake > 0.0f) { + float speed = fabsf(m_physics->getVehicleForwardSpeed( + vehicle.constraint)); + if (speed > 0.5f) + brake = vehicle.engineBrake; + } + m_physics->setVehicleInput(vehicle.constraint, forward, - vehicle.inputRight, brake, - handbrake); + vehicle.rearSteer ? + -vehicle.inputRight : + vehicle.inputRight, + brake, handbrake); + + /* Smooth the road-wheel steer angle for the steering + * wheel visual (keyboard input is on/off; the visual + * should sweep). The sign follows what the driver + * sees, not the raw wheel angle: D = wheel spins + * clockwise = nose right. */ + float maxSteer = 0.0f; + for (const VehicleWheel &w : vehicle.wheels) + maxSteer = std::max(maxSteer, w.maxSteerAngleDeg); + float steerTarget = vehicle.inputRight * maxSteer; + float &steer = m_steerSmooth[entity.id()]; + float blend = std::min(1.0f, deltaTime * 8.0f); + steer += (steerTarget - steer) * blend; + + updateFork(entity, vehicle, rigidBody, deltaTime); }); } @@ -219,5 +419,26 @@ void VehicleSystem::postPhysicsUpdate() pos)); node->setOrientation(invChassis * ori); } + + /* Fork visual: follow the fork height. */ + if (it->second.forkNode) + it->second.forkNode->setPosition( + vehicle.forkOffset + + Ogre::Vector3(0.0f, vehicle.forkHeight, + 0.0f)); + + /* Steering wheel visual: spin around the tilted local Y + * with the smoothed road-wheel angle. */ + if (it->second.steeringNode) { + auto sit = m_steerSmooth.find(entity.id()); + float steer = sit != m_steerSmooth.end() ? sit->second + : 0.0f; + it->second.steeringNode->setOrientation( + it->second.steeringBaseOri * + Ogre::Quaternion( + Ogre::Degree(-steer * + vehicle.steeringRatio), + Ogre::Vector3::UNIT_Y)); + } }); } diff --git a/src/features/editScene/systems/VehicleSystem.hpp b/src/features/editScene/systems/VehicleSystem.hpp index 2d45732..937af13 100644 --- a/src/features/editScene/systems/VehicleSystem.hpp +++ b/src/features/editScene/systems/VehicleSystem.hpp @@ -36,10 +36,55 @@ public: void prePhysicsUpdate(float deltaTime); void postPhysicsUpdate(); + /* Test API: the vehicle's steering wheel visual node (nullptr when + * the vehicle has no steeringWheelMeshName or no constraint yet). */ + Ogre::SceneNode *getSteeringWheelNode(flecs::entity_t id) const + { + auto it = m_wheelVisuals.find(id); + return it != m_wheelVisuals.end() ? it->second.steeringNode + : nullptr; + } + + /* Test API: the fork visual node (nullptr when the vehicle has no + * forkMeshName or no constraint yet). */ + Ogre::SceneNode *getForkNode(flecs::entity_t id) const + { + auto it = m_wheelVisuals.find(id); + return it != m_wheelVisuals.end() ? it->second.forkNode + : nullptr; + } + + /* Test API: entity currently carried on the vehicle's fork + * (0 when none). */ + flecs::entity_t getCarriedEntity(flecs::entity_t id) const + { + auto it = m_carried.find(id); + return it != m_carried.end() ? it->second.crateId : 0; + } + private: struct WheelVisual { std::vector nodes; std::vector entities; + /* Steering wheel visual (optional): spun around its local Y + * with the smoothed steering angle. */ + Ogre::SceneNode *steeringNode = nullptr; + Ogre::Entity *steeringEntity = nullptr; + Ogre::Quaternion steeringBaseOri = Ogre::Quaternion::IDENTITY; + /* Fork visual (optional): raised/lowered with the fork. */ + Ogre::SceneNode *forkNode = nullptr; + Ogre::Entity *forkEntity = nullptr; + }; + + /* A PushableComponent crate pinned to the fork carriage. */ + struct CarriedCrate { + flecs::entity_t crateId = 0; + JPH::BodyID bodyID; + /* Crate pose relative to the fork carriage at attach time + * (chassis space; forkHeight is added to y while carried). */ + Ogre::Vector3 relOffset = Ogre::Vector3::ZERO; + Ogre::Quaternion relOri = Ogre::Quaternion::IDENTITY; + float savedGravityFactor = 1.0f; }; void createConstraint(flecs::entity entity, VehicleComponent &vehicle, @@ -48,6 +93,11 @@ private: void createWheelVisuals(flecs::entity entity, VehicleComponent &vehicle); void destroyWheelVisuals(flecs::entity_t id); + void updateFork(flecs::entity entity, VehicleComponent &vehicle, + RigidBodyComponent &rigidBody, float deltaTime); + void tryForkAttach(flecs::entity_t vehicleId, VehicleComponent &vehicle, + RigidBodyComponent &rigidBody); + void forkDetach(flecs::entity_t vehicleId); flecs::world &m_world; JoltPhysicsWrapper *m_physics; @@ -58,6 +108,11 @@ private: std::map m_wheelVisuals; /* Per-vehicle previous forward input for brake/reverse logic. */ std::map m_prevForward; + /* Per-vehicle smoothed road-wheel steer angle (degrees) driving + * the steering wheel visual. */ + std::map m_steerSmooth; + /* Per-vehicle crate carried on the fork. */ + std::map m_carried; }; #endif // EDITSCENE_VEHICLESYSTEM_HPP diff --git a/src/features/editScene/systems/ZoneSystem.cpp b/src/features/editScene/systems/ZoneSystem.cpp new file mode 100644 index 0000000..7391987 --- /dev/null +++ b/src/features/editScene/systems/ZoneSystem.cpp @@ -0,0 +1,186 @@ +#include "ZoneSystem.hpp" +#include "EventBus.hpp" +#include "RenderOriginSystem.hpp" +#include "../components/Pushable.hpp" +#include "../components/RigidBody.hpp" +#include "../components/Transform.hpp" +#include "../components/EntityName.hpp" +#include "../components/Renderable.hpp" +#include "../physics/physics.h" +#include +#include +#include + +ZoneSystem::ZoneSystem(flecs::world &world, JoltPhysicsWrapper *physics) + : m_world(world), m_physics(physics) +{ +} + +bool ZoneSystem::isZoneCovered(const Ogre::String &zoneId) const +{ + auto it = m_zones.find(zoneId); + return it != m_zones.end() && !it->second.occupants.empty(); +} + +int ZoneSystem::getZoneOccupantCount(const Ogre::String &zoneId) const +{ + auto it = m_zones.find(zoneId); + return it != m_zones.end() ? (int)it->second.occupants.size() : 0; +} + +void ZoneSystem::applyZoneMaterial(flecs::entity zoneEntity, + const TargetZoneComponent &zone, bool covered) +{ + const Ogre::String &material = + covered ? zone.coveredMaterialName : zone.uncoveredMaterialName; + if (material.empty() || !zoneEntity.has()) + return; + Ogre::Entity *ogreEntity = + zoneEntity.get().entity; + if (!ogreEntity) + return; + if (!Ogre::MaterialManager::getSingleton().resourceExists(material)) + return; + for (unsigned int i = 0; i < ogreEntity->getNumSubEntities(); i++) + ogreEntity->getSubEntity(i)->setMaterialName(material); +} + +void ZoneSystem::update() +{ + if (!m_physics) + return; + + /* Snapshot the pushable props: position + speed. */ + struct Prop { + flecs::entity_t id; + Ogre::String name; + Ogre::Vector3 pos; + float speed; + }; + std::vector props; + m_world.query() + .each([&](flecs::entity e, PushableComponent &pushable, + TransformComponent &t, RigidBodyComponent &rb) { + if (!pushable.enabled) + return; + Prop p; + p.id = e.id(); + p.name = e.has() ? + e.get().name : + Ogre::StringConverter::toString( + (unsigned long long)e.id()); + p.pos = t.node ? t.node->_getDerivedPosition() : + t.position; + p.speed = 1.0e9f; /* no body yet: never at rest */ + if (rb.bodyCreated) { + /* The physics body is authoritative: the scene + * node lags a frame behind a teleport, which + * would produce phantom zone entries. Convert + * back to render space to match the zone + * centers below. */ + Ogre::Vector3 bodyPos = + m_physics->getPosition(rb.bodyID); + RenderOriginSystem *ro = + RenderOriginSystem::getInstance(); + if (ro) + p.pos = ro->worldToRender(bodyPos.x, + bodyPos.y, + bodyPos.z); + else + p.pos = bodyPos; + p.speed = m_physics->getLinearVelocity(rb.bodyID) + .length(); + } + props.push_back(p); + }); + + /* Diff every live zone against its previous occupants. */ + std::map seen; + m_world.query().each( + [&](flecs::entity e, TargetZoneComponent &zone, + TransformComponent &t) { + if (!zone.enabled || zone.zoneId.empty()) + return; + seen[zone.zoneId] = true; + ZoneState &state = m_zones[zone.zoneId]; + + Ogre::Vector3 center = t.node ? + t.node->_getDerivedPosition() : + t.position; + + std::vector now; + for (const Prop &p : props) { + if (p.speed > 0.2f) + continue; + if (fabsf(p.pos.x - center.x) > + zone.halfExtents.x || + fabsf(p.pos.z - center.z) > + zone.halfExtents.z) + continue; + if (fabsf(p.pos.y - center.y) > 1.0f) + continue; + now.push_back({ p.id, p.name }); + } + + for (const Occupant &old : state.occupants) { + bool still = false; + for (const Occupant &n : now) + if (n.id == old.id) { + still = true; + break; + } + if (!still) { + editScene::EventParams params; + params.setString("zone_id", + zone.zoneId); + params.setString("entity", old.name); + params.setEntityId("entity_id", + old.id); + EventBus::getInstance().send( + "zone_left", params); + } + } + for (const Occupant &n : now) { + bool known = false; + for (const Occupant &old : state.occupants) + if (old.id == n.id) { + known = true; + break; + } + if (!known) { + editScene::EventParams params; + params.setString("zone_id", + zone.zoneId); + params.setString("entity", n.name); + params.setEntityId("entity_id", n.id); + EventBus::getInstance().send( + "zone_entered", params); + } + } + state.occupants = now; + + bool covered = !now.empty(); + if (covered != state.materialCovered) { + applyZoneMaterial(e, zone, covered); + state.materialCovered = covered; + } + }); + + /* Zones that vanished (scene switch): flush their occupants with + * zone_left so Lua-side trackers never go stale. */ + for (auto it = m_zones.begin(); it != m_zones.end();) { + if (seen.count(it->first)) { + ++it; + continue; + } + for (const Occupant &old : it->second.occupants) { + editScene::EventParams params; + params.setString("zone_id", it->first); + params.setString("entity", old.name); + params.setEntityId("entity_id", old.id); + EventBus::getInstance().send("zone_left", params); + } + it = m_zones.erase(it); + } +} diff --git a/src/features/editScene/systems/ZoneSystem.hpp b/src/features/editScene/systems/ZoneSystem.hpp new file mode 100644 index 0000000..52619ed --- /dev/null +++ b/src/features/editScene/systems/ZoneSystem.hpp @@ -0,0 +1,66 @@ +#ifndef EDITSCENE_ZONESYSTEM_HPP +#define EDITSCENE_ZONESYSTEM_HPP +#pragma once + +#include +#include +#include +#include +#include "../components/TargetZone.hpp" + +class JoltPhysicsWrapper; + +/** + * Zone system (F11, see demos/demo-sokoban/PLAN.md Phase 6). + * + * Per-frame detection of PushableComponent props resting inside + * TargetZoneComponent zones: a prop covers a zone while its centre is + * inside the zone's XZ box (|dy| < 1) and it is nearly at rest + * (|v| < 0.2 m/s). Coverage changes emit EventBus events: + * + * zone_entered / zone_left + * params: zone_id (string), entity (entity name string), + * entity_id (entity id) + * + * The occupant diff also handles entity destruction (scene switch): + * occupants that vanished emit zone_left, so Lua-side trackers never + * go stale. Zones themselves disappearing flush all their occupants + * the same way. + * + * Visual feedback: when covered/uncoveredMaterialName are set on the + * zone, the zone entity's renderable material is swapped accordingly + * (skipped when the material does not exist, e.g. in the editor where + * the demo materials are not created). + * + * Runs in game mode only (Game+Playing) — see EditorApp. + */ +class ZoneSystem { +public: + ZoneSystem(flecs::world &world, JoltPhysicsWrapper *physics); + + void update(); + + /* Test/query API. */ + bool isZoneCovered(const Ogre::String &zoneId) const; + int getZoneOccupantCount(const Ogre::String &zoneId) const; + +private: + struct Occupant { + flecs::entity_t id; + Ogre::String name; + }; + struct ZoneState { + std::vector occupants; + bool materialCovered = false; + }; + + void applyZoneMaterial(flecs::entity zoneEntity, + const TargetZoneComponent &zone, bool covered); + + flecs::world &m_world; + JoltPhysicsWrapper *m_physics; + /* zoneId -> state. */ + std::map m_zones; +}; + +#endif // EDITSCENE_ZONESYSTEM_HPP diff --git a/src/features/editScene/ui/PushableEditor.cpp b/src/features/editScene/ui/PushableEditor.cpp new file mode 100644 index 0000000..ef8828e --- /dev/null +++ b/src/features/editScene/ui/PushableEditor.cpp @@ -0,0 +1,14 @@ +#include "PushableEditor.hpp" +#include + +bool PushableEditor::renderComponent(flecs::entity entity, + PushableComponent &pushable) +{ + (void)entity; + bool modified = ImGui::Checkbox("Enabled", &pushable.enabled); + ImGui::TextWrapped( + "Marker: a dynamic prop that activities (e.g. sokoban zone " + "detection) care about. Give the entity a dynamic rigid body " + "and a box collider."); + return modified; +} diff --git a/src/features/editScene/ui/PushableEditor.hpp b/src/features/editScene/ui/PushableEditor.hpp new file mode 100644 index 0000000..f714461 --- /dev/null +++ b/src/features/editScene/ui/PushableEditor.hpp @@ -0,0 +1,20 @@ +#ifndef EDITSCENE_PUSHABLE_EDITOR_HPP +#define EDITSCENE_PUSHABLE_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/Pushable.hpp" + +class PushableEditor : public ComponentEditor { +public: + const char *getName() const override + { + return "Pushable"; + } + +protected: + bool renderComponent(flecs::entity entity, + PushableComponent &pushable) override; +}; + +#endif // EDITSCENE_PUSHABLE_EDITOR_HPP diff --git a/src/features/editScene/ui/TargetZoneEditor.cpp b/src/features/editScene/ui/TargetZoneEditor.cpp new file mode 100644 index 0000000..12112da --- /dev/null +++ b/src/features/editScene/ui/TargetZoneEditor.cpp @@ -0,0 +1,49 @@ +#include "TargetZoneEditor.hpp" +#include +#include + +bool TargetZoneEditor::renderComponent(flecs::entity entity, + TargetZoneComponent &zone) +{ + (void)entity; + bool modified = false; + + char idBuf[256] = { 0 }; + strncpy(idBuf, zone.zoneId.c_str(), sizeof(idBuf) - 1); + if (ImGui::InputText("Zone Id", idBuf, sizeof(idBuf))) { + zone.zoneId = idBuf; + modified = true; + } + + if (ImGui::InputFloat3("Half Extents", &zone.halfExtents.x)) + modified = true; + + char uncoveredBuf[256] = { 0 }; + strncpy(uncoveredBuf, zone.uncoveredMaterialName.c_str(), + sizeof(uncoveredBuf) - 1); + if (ImGui::InputText("Uncovered Material", uncoveredBuf, + sizeof(uncoveredBuf))) { + zone.uncoveredMaterialName = uncoveredBuf; + modified = true; + } + + char coveredBuf[256] = { 0 }; + strncpy(coveredBuf, zone.coveredMaterialName.c_str(), + sizeof(coveredBuf) - 1); + if (ImGui::InputText("Covered Material", coveredBuf, + sizeof(coveredBuf))) { + zone.coveredMaterialName = coveredBuf; + modified = true; + } + + if (ImGui::Checkbox("Enabled", &zone.enabled)) + modified = true; + + ImGui::TextWrapped( + "Goal zone for activities (e.g. sokoban): ZoneSystem tests " + "Pushable props for XZ-overlap + nearly-at-rest against this " + "box and emits zone_entered/zone_left, swapping the material " + "for covered/uncovered feedback. Zone ids must be unique per " + "yard (e.g. \"yard1.pad1\")."); + return modified; +} diff --git a/src/features/editScene/ui/TargetZoneEditor.hpp b/src/features/editScene/ui/TargetZoneEditor.hpp new file mode 100644 index 0000000..985f761 --- /dev/null +++ b/src/features/editScene/ui/TargetZoneEditor.hpp @@ -0,0 +1,20 @@ +#ifndef EDITSCENE_TARGET_ZONE_EDITOR_HPP +#define EDITSCENE_TARGET_ZONE_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/TargetZone.hpp" + +class TargetZoneEditor : public ComponentEditor { +public: + const char *getName() const override + { + return "TargetZone"; + } + +protected: + bool renderComponent(flecs::entity entity, + TargetZoneComponent &zone) override; +}; + +#endif // EDITSCENE_TARGET_ZONE_EDITOR_HPP diff --git a/src/features/editScene/ui/VehicleEditor.cpp b/src/features/editScene/ui/VehicleEditor.cpp index bf14888..1618891 100644 --- a/src/features/editScene/ui/VehicleEditor.cpp +++ b/src/features/editScene/ui/VehicleEditor.cpp @@ -20,6 +20,19 @@ bool VehicleEditor::renderComponent(flecs::entity entity, if (ImGui::DragFloat3("Seat Offset", &vehicle.seatOffset.x, 0.01f)) modified = true; + if (ImGui::DragFloat("Camera Distance", + &vehicle.cameraDistance, 0.05f, + 1.0f, 50.0f)) + modified = true; + if (ImGui::DragFloat("Camera Height", + &vehicle.cameraHeight, 0.05f, + 0.0f, 20.0f)) + modified = true; + if (ImGui::Checkbox("Rear Steering", &vehicle.rearSteer)) + modified = true; + if (ImGui::DragFloat("Engine Brake", &vehicle.engineBrake, + 0.05f, 0.0f, 1.0f)) + modified = true; char wheelMeshBuf[256]; snprintf(wheelMeshBuf, sizeof(wheelMeshBuf), "%s", @@ -30,6 +43,43 @@ bool VehicleEditor::renderComponent(flecs::entity entity, modified = true; } + char steeringMeshBuf[256]; + snprintf(steeringMeshBuf, sizeof(steeringMeshBuf), "%s", + vehicle.steeringWheelMeshName.c_str()); + if (ImGui::InputText("Steering Wheel Mesh", steeringMeshBuf, + sizeof(steeringMeshBuf))) { + vehicle.steeringWheelMeshName = steeringMeshBuf; + modified = true; + } + if (ImGui::DragFloat3("Steering Wheel Offset", + &vehicle.steeringWheelOffset.x, 0.01f)) + modified = true; + if (ImGui::DragFloat("Steering Wheel Tilt (deg)", + &vehicle.steeringWheelTiltDeg, 0.5f, + 0.0f, 90.0f)) + modified = true; + if (ImGui::DragFloat("Steering Ratio", &vehicle.steeringRatio, + 0.1f, 0.0f, 30.0f)) + modified = true; + + char forkMeshBuf[256]; + snprintf(forkMeshBuf, sizeof(forkMeshBuf), "%s", + vehicle.forkMeshName.c_str()); + if (ImGui::InputText("Fork Mesh", forkMeshBuf, + sizeof(forkMeshBuf))) { + vehicle.forkMeshName = forkMeshBuf; + modified = true; + } + if (ImGui::DragFloat3("Fork Offset", &vehicle.forkOffset.x, + 0.01f)) + modified = true; + if (ImGui::DragFloat("Fork Max Height", &vehicle.forkMaxHeight, + 0.05f, 0.0f, 5.0f)) + modified = true; + if (ImGui::DragFloat("Fork Speed", &vehicle.forkSpeed, 0.05f, + 0.0f, 5.0f)) + modified = true; + ImGui::Separator(); ImGui::Text("Wheels (%d)", (int)vehicle.wheels.size()); ImGui::SameLine();