Sokoban demo is complete
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ¶ms) {
|
||||
onVehicleEnterEvent(eventName, params);
|
||||
});
|
||||
}
|
||||
|
||||
VehicleControllerSystem::~VehicleControllerSystem()
|
||||
{
|
||||
EventBus::getInstance().unsubscribe(m_enterListener);
|
||||
}
|
||||
|
||||
flecs::entity VehicleControllerSystem::getDrivenVehicle() const
|
||||
{
|
||||
if (!m_driving)
|
||||
return flecs::entity::null();
|
||||
return m_world.entity(m_vehicleId);
|
||||
}
|
||||
|
||||
flecs::entity VehicleControllerSystem::findPlayerController()
|
||||
{
|
||||
flecs::entity result = flecs::entity::null();
|
||||
m_world.query<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 ¶ms)
|
||||
{
|
||||
(void)eventName;
|
||||
if (m_driving)
|
||||
return;
|
||||
|
||||
Ogre::String vehicleName = params.getString("vehicle", "");
|
||||
if (vehicleName.empty()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"[VehicleControllerSystem] vehicle_enter event without "
|
||||
"a 'vehicle' param, ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
flecs::entity vehicle = flecs::entity::null();
|
||||
m_world.query<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 ¶ms);
|
||||
flecs::entity findPlayerController();
|
||||
void pinCharacterToSeat(flecs::entity vehicle,
|
||||
flecs::entity character);
|
||||
bool findExitSpot(flecs::entity vehicle, Ogre::Vector3 &out) const;
|
||||
void restoreOnFoot();
|
||||
|
||||
flecs::world &m_world;
|
||||
EditorApp *m_editorApp;
|
||||
EventBus::ListenerId m_enterListener = 0;
|
||||
|
||||
bool m_driving = false;
|
||||
flecs::entity_t m_vehicleId = 0;
|
||||
flecs::entity_t m_characterId = 0;
|
||||
flecs::entity_t m_controllerId = 0;
|
||||
/* Stashed on-foot camera settings, restored on exit. */
|
||||
float m_savedTpsDistance = 3.0f;
|
||||
float m_savedTpsHeight = 2.0f;
|
||||
/* Exit debounce: time seated and current E hold duration. */
|
||||
float m_seatTime = 0.0f;
|
||||
float m_exitHold = 0.0f;
|
||||
};
|
||||
|
||||
#endif // EDITSCENE_VEHICLECONTROLLERSYSTEM_HPP
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user