Sokoban demo is complete

This commit is contained in:
2026-09-15 12:52:53 +03:00
parent dd1835b4a3
commit d300a16333
45 changed files with 7187 additions and 170 deletions
+258 -28
View File
@@ -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<T>()` 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.<id>.progress` and latches `quest.<id>.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.<id>` status line ("<questName>: crates n/m") from
registration on and a "Quest completed: <questName>" 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 = "<static entity>", 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=<entity name>` 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
+9
View File
@@ -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
)
+166 -1
View File
@@ -8,6 +8,8 @@
#include <OgreMaterialManager.h>
#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<VehicleSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
/* F11 target zones (needs the physics wrapper). */
m_zoneSystem = std::make_unique<ZoneSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
/* F12 game HUD overlay (ImGui, Lua-driven). */
m_gameHudSystem = std::make_unique<GameHudSystem>(this);
// Setup buoyancy system (requires physics system)
// Get the physics wrapper from the physics system
m_buoyancySystem = std::make_unique<BuoyancySystem>(
@@ -785,6 +807,13 @@ void EditorApp::setup()
std::make_unique<PlayerControllerSystem>(
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<VehicleControllerSystem>(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<TransformComponent>())
return false;
const TransformComponent &t =
e.get<TransformComponent>();
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<RigidBodyComponent>()) {
const RigidBodyComponent &rb =
e.get<RigidBodyComponent>();
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<TransformComponent>())
return false;
auto &t = e.get_mut<TransformComponent>();
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<ActuatorComponent>();
// Register Pushable marker component (F11)
m_world.component<PushableComponent>();
// Register Target Zone component (F11)
m_world.component<TargetZoneComponent>();
// Register Event Handler component
m_world.component<EventHandlerComponent>();
@@ -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;
+34
View File
@@ -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<PlayerControllerSystem> m_playerControllerSystem;
/* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */
std::unique_ptr<VehicleSystem> m_vehicleSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F10 enter/exit + drive input for vehicles (game mode). */
std::unique_ptr<VehicleControllerSystem> m_vehicleControllerSystem;
// State
uint16_t m_currentModifiers;
@@ -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.<id>.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 ("<questName>: crates n/m" status,
"Quest completed: <questName>" banner). A future quest system reuses
the same display.
---
## Cross-cutting requirements
For every feature above, per project rules (`AGENTS.md`):
@@ -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";
};
/**
@@ -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
@@ -0,0 +1,20 @@
#include "Pushable.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PushableEditor.hpp"
REGISTER_COMPONENT_GROUP("Pushable", "Game", PushableComponent,
PushableEditor)
{
registry.registerComponent<PushableComponent>(
"Pushable", "Game", std::make_unique<PushableEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PushableComponent>())
e.set<PushableComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PushableComponent>())
e.remove<PushableComponent>();
});
}
@@ -0,0 +1,34 @@
#ifndef EDITSCENE_TARGET_ZONE_HPP
#define EDITSCENE_TARGET_ZONE_HPP
#pragma once
#include <Ogre.h>
/**
* 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
@@ -0,0 +1,20 @@
#include "TargetZone.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TargetZoneEditor.hpp"
REGISTER_COMPONENT_GROUP("TargetZone", "Game", TargetZoneComponent,
TargetZoneEditor)
{
registry.registerComponent<TargetZoneComponent>(
"TargetZone", "Game", std::make_unique<TargetZoneEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<TargetZoneComponent>())
e.set<TargetZoneComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<TargetZoneComponent>())
e.remove<TargetZoneComponent>();
});
}
+55 -5
View File
@@ -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;
+544 -96
View File
@@ -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=<entity name>`.
`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 <name>` 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
(`<yardId>.pad<n>`), 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.<id>.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.<id>.completed", true)` and
`quest.<id>.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.<id>` shows
"<questName>: crates n/m" from registration on (0/m until zones
report), updates on every zone event, and pushes the
"Quest completed: <questName>" 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).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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}"
}
}
],
@@ -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"
@@ -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}")
@@ -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()
@@ -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
@@ -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 = <id> }
--
-- 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.<id>.progress (int) pads currently covered
-- quest.<id>.completed (bool) latched once all pads are covered
--
-- ...and through events:
--
-- sokoban_progress { yard = <id>, covered = n, total = m }
-- quest_completed { quest_id = <id>, quest_name = <questName> }
--
-- ...and through the game HUD (F12, ecs.hud.*, no-op without a HUD):
--
-- status key "sokoban.<id>" -> "<questName>: crates n/m"
-- completion banner -> "Quest completed: <questName>"
--
-- 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
+153
View File
@@ -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<flecs::entity()> s_playerCharacterResolver;
static std::function<bool(flecs::entity, Ogre::Vector3 &)>
s_entityPositionGetter;
static std::function<bool(flecs::entity, const Ogre::Vector3 &, bool,
float)>
s_entityTeleporter;
static std::function<bool(const std::string &)> s_zoneCoverageChecker;
void setLuaPlayerCharacterResolver(
const std::function<flecs::entity()> &resolver)
{
s_playerCharacterResolver = resolver;
}
void setLuaEntityPositionGetter(
const std::function<bool(flecs::entity, Ogre::Vector3 &)> &getter)
{
s_entityPositionGetter = getter;
}
void setLuaEntityTeleporter(
const std::function<bool(flecs::entity, const Ogre::Vector3 &, bool,
float)> &teleporter)
{
s_entityTeleporter = teleporter;
}
void setLuaZoneCoverageChecker(
const std::function<bool(const std::string &)> &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");
}
@@ -5,6 +5,7 @@
#include <flecs.h>
#include <lua.hpp>
#include <Ogre.h>
#include <functional>
#include <unordered_map>
/**
@@ -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<flecs::entity()> &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<bool(flecs::entity, Ogre::Vector3 &)>
&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<bool(flecs::entity, const Ogre::Vector3 &, bool,
float)> &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<bool(const std::string &)> &checker);
/**
* @brief Convert a Flecs entity to a Lua integer ID.
* @return The integer ID, or -1 if the entity is invalid.
+60
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
#ifndef EDITSCENE_LUA_HUD_API_HPP
#define EDITSCENE_LUA_HUD_API_HPP
#pragma once
#include <lua.hpp>
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
@@ -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);
+2
View File
@@ -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);
@@ -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<PlayerControllerComponent>().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
@@ -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;
@@ -0,0 +1,96 @@
#include "GameHudSystem.hpp"
#include "../EditorApp.hpp"
#include <imgui.h>
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();
}
@@ -0,0 +1,59 @@
#ifndef EDITSCENE_GAMEHUDSYSTEM_HPP
#define EDITSCENE_GAMEHUDSYSTEM_HPP
#pragma once
#include <Ogre.h>
#include <map>
#include <vector>
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<Ogre::String, Ogre::String> m_statusLines;
std::vector<Message> m_messages;
};
#endif // EDITSCENE_GAMEHUDSYSTEM_HPP
@@ -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<PlayerControllerComponent>().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<TransformComponent>())
return;
auto &transform =
state.targetEntity.get<TransformComponent>();
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<CharacterComponent>())
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();
@@ -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
@@ -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<ActuatorComponent>()) {
json["actuator"] = serializeActuator(entity);
}
if (entity.has<PushableComponent>()) {
json["pushable"] = serializePushable(entity);
}
if (entity.has<TargetZoneComponent>()) {
json["targetZone"] = serializeTargetZone(entity);
}
if (entity.has<EventHandlerComponent>()) {
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<ActuatorComponent>(actuator);
}
nlohmann::json SceneSerializer::serializePushable(flecs::entity entity)
{
const PushableComponent &pushable = entity.get<PushableComponent>();
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<PushableComponent>(pushable);
}
nlohmann::json SceneSerializer::serializeTargetZone(flecs::entity entity)
{
const TargetZoneComponent &zone = entity.get<TargetZoneComponent>();
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<TargetZoneComponent>(zone);
}
nlohmann::json SceneSerializer::serializeEventHandler(flecs::entity entity)
{
const EventHandlerComponent &handler =
@@ -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,
@@ -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 <OgreLogManager.h>
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 &params) {
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<EntityNameComponent, PlayerControllerComponent>().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 &params)
{
(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<EntityNameComponent>().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<VehicleComponent>() ||
!vehicle.has<TransformComponent>())
return false;
const TransformComponent &vt = vehicle.get<TransformComponent>();
if (!vt.node)
return false;
flecs::entity character = m_editorApp->getPlayerCharacterEntity();
if (!character.is_alive() ||
!character.has<TransformComponent>())
return false;
const TransformComponent &ct = character.get<TransformComponent>();
if (!ct.node)
return false;
flecs::entity controller = findPlayerController();
if (!controller.is_alive())
return false;
auto &pc = controller.get_mut<PlayerControllerComponent>();
m_savedTpsDistance = pc.tpsDistance;
m_savedTpsHeight = pc.tpsHeight;
pc.driving = true;
pc.tpsDistance = vehicle.get<VehicleComponent>().cameraDistance;
pc.tpsHeight = vehicle.get<VehicleComponent>().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<PlayerControlledComponent>();
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<VehicleComponent>().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<TransformComponent>();
const VehicleComponent &vc = vehicle.get<VehicleComponent>();
auto &ct = character.get_mut<TransformComponent>();
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<VehicleComponent>()) {
if (character.is_alive())
restoreOnFoot();
m_driving = false;
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
return;
}
if (!character.is_alive()) {
vehicle.remove<PlayerControlledComponent>();
m_driving = false;
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
return;
}
GameInputState &input = m_editorApp->getGameInputState();
auto &vc = vehicle.get_mut<VehicleComponent>();
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<VehicleComponent>() ||
!vehicle.has<TransformComponent>() || !character.is_alive() ||
!character.has<TransformComponent>())
return;
pinCharacterToSeat(vehicle, character);
}
bool VehicleControllerSystem::findExitSpot(flecs::entity vehicle,
Ogre::Vector3 &out) const
{
const TransformComponent &vt = vehicle.get<TransformComponent>();
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<PlayerControllerComponent>()) {
auto &pc = controller.get_mut<PlayerControllerComponent>();
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<TransformComponent>();
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<VehicleComponent>();
vc.inputForward = 0.0f;
vc.inputRight = 0.0f;
vc.inputBrake = 0.0f;
vc.inputHandBrake = 0.0f;
vc.inputFork = 0.0f;
vehicle.remove<PlayerControlledComponent>();
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<VehicleComponent>()) {
auto &vc = vehicle.get_mut<VehicleComponent>();
vc.inputForward = 0.0f;
vc.inputRight = 0.0f;
vc.inputBrake = 0.0f;
vc.inputHandBrake = 0.0f;
vc.inputFork = 0.0f;
}
vehicle.remove<PlayerControlledComponent>();
}
restoreOnFoot();
m_driving = false;
m_vehicleId = 0;
m_characterId = 0;
m_controllerId = 0;
}
@@ -0,0 +1,99 @@
#ifndef EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP
#define EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP
#pragma once
#include "EventBus.hpp"
#include <Ogre.h>
#include <flecs.h>
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 &params);
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
@@ -1,4 +1,5 @@
#include "VehicleSystem.hpp"
#include "../components/Pushable.hpp"
#include "../components/RigidBody.hpp"
#include "../components/Transform.hpp"
#include <Jolt/Physics/Vehicle/VehicleConstraint.h>
@@ -17,6 +18,7 @@ VehicleSystem::VehicleSystem(flecs::world &world, JoltPhysicsWrapper *physics)
m_world.observer<VehicleComponent>("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<RigidBodyComponent>() ||
!crate.get<RigidBodyComponent>().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<PushableComponent, RigidBodyComponent>().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<RigidBodyComponent>().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<RigidBodyComponent>() &&
crate.get<RigidBodyComponent>().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<TransformComponent>())
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));
}
});
}
@@ -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<Ogre::SceneNode *> nodes;
std::vector<Ogre::Entity *> 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<flecs::entity_t, WheelVisual> m_wheelVisuals;
/* Per-vehicle previous forward input for brake/reverse logic. */
std::map<flecs::entity_t, float> m_prevForward;
/* Per-vehicle smoothed road-wheel steer angle (degrees) driving
* the steering wheel visual. */
std::map<flecs::entity_t, float> m_steerSmooth;
/* Per-vehicle crate carried on the fork. */
std::map<flecs::entity_t, CarriedCrate> m_carried;
};
#endif // EDITSCENE_VEHICLESYSTEM_HPP
@@ -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 <OgreEntity.h>
#include <OgreSubEntity.h>
#include <OgreMaterialManager.h>
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<RenderableComponent>())
return;
Ogre::Entity *ogreEntity =
zoneEntity.get<RenderableComponent>().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<Prop> props;
m_world.query<PushableComponent, TransformComponent,
RigidBodyComponent>()
.each([&](flecs::entity e, PushableComponent &pushable,
TransformComponent &t, RigidBodyComponent &rb) {
if (!pushable.enabled)
return;
Prop p;
p.id = e.id();
p.name = e.has<EntityNameComponent>() ?
e.get<EntityNameComponent>().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<Ogre::String, bool> seen;
m_world.query<TargetZoneComponent, TransformComponent>().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<Occupant> 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);
}
}
@@ -0,0 +1,66 @@
#ifndef EDITSCENE_ZONESYSTEM_HPP
#define EDITSCENE_ZONESYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <map>
#include <vector>
#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<Occupant> 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<Ogre::String, ZoneState> m_zones;
};
#endif // EDITSCENE_ZONESYSTEM_HPP
@@ -0,0 +1,14 @@
#include "PushableEditor.hpp"
#include <imgui.h>
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;
}
@@ -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<PushableComponent> {
public:
const char *getName() const override
{
return "Pushable";
}
protected:
bool renderComponent(flecs::entity entity,
PushableComponent &pushable) override;
};
#endif // EDITSCENE_PUSHABLE_EDITOR_HPP
@@ -0,0 +1,49 @@
#include "TargetZoneEditor.hpp"
#include <imgui.h>
#include <cstring>
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;
}
@@ -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<TargetZoneComponent> {
public:
const char *getName() const override
{
return "TargetZone";
}
protected:
bool renderComponent(flecs::entity entity,
TargetZoneComponent &zone) override;
};
#endif // EDITSCENE_TARGET_ZONE_EDITOR_HPP
@@ -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();