Compare commits

..

4 Commits

Author SHA1 Message Date
slapin dd1835b4a3 Sokoban demo: phase 1 complete 2026-09-12 05:31:07 +03:00
slapin 7a388d2a6f Fixed light bleeding throw scene switching doors 2026-09-11 21:53:36 +03:00
slapin 7aa7682e97 Fixed black tunnels 2026-09-11 18:39:02 +03:00
slapin 1159bb5a47 Visual barriers for doors are now black 2026-09-11 16:38:12 +03:00
38 changed files with 6744 additions and 163 deletions
+105 -12
View File
@@ -196,6 +196,32 @@ cd demos/demo-interior-exterior-dynamics
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
# Demo: forklift sokoban activity in the open world
# (demos/demo-sokoban, target demoSokoban). Starts as an exact copy of
# demo-interior-exterior-dynamics (same interior/exterior scenes,
# streaming terrain, scene-switch doors, --test-switch contract); a
# drivable forklift (Jolt VehicleConstraint), pushable crates, target
# pads and a completion HUD are added to demo_scene_exterior.json step
# by step - see demos/demo-sokoban/PLAN.md (F10 vehicle, F11 sokoban,
# F12 HUD status display).
cd demos/demo-sokoban
./demoSokoban
# ...or headless smoke run (one frame, then exit):
./demoSokoban --headless --exit-after-first-frame
# ...or headless end-to-end checks (judge by the "[test] PASS" line in
# stdout; a known pre-existing teardown segfault in WaterPlane RTT
# viewport destruction can mask the exit code with 139):
./demoSokoban --headless --test-switch
# ...vehicle check on the streaming-terrain exterior scene (spawn,
# settle, drive forward, brake to a stop; a grounding watchdog
# re-teleports the chassis while streaming page colliders build):
./demoSokoban --headless --test-vehicle
# ...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).
# Road wedge/segment self-intersection regression test (no scene needed,
# also registered as CTest roadGeometryOverlapTest)
@@ -633,14 +659,38 @@ sceneSwitchTarget})` and emits the `door_scene_switch` event (params:
`path`, `target`, `door_id`) only when the leaf reaches `openAngle` — the
existing loading cover hides the transition. Pressing E on an
already-open scene-switch door switches immediately; a close cancels a
pending switch. The prompt stays "E Open" (never "E Close"). The door
builder also adds an unlit black occluder box (`CellGridDoorOccluderBox`
mesh + `CellGridDoorOccluderBlack` material) covering the doorway a few
cm behind the closed leaf plane, childed to the grid node (it does NOT
swing with the hinge) and tracked via `DoorComponent::occluder`;
`DoorSystem` hides it while the door is fully closed so the player never
sees the missing room interior through the opened doorway before the
switch fires.
pending switch. The prompt stays "E Open" (never "E Close"). Unlike normal
persistent doors, scene-switch doors never restore the persisted open
state: `DoorSystem::declareDoorDefaults(..., sceneSwitchDoor = true)`
resets their stored `isOpen`, so a door the player left through is always
closed again when the scene reloads (before the loading cover lifts). The
door builder also adds a black occluder covering the doorway, childed to
the grid node (it does NOT swing with the hinge) and tracked via
`DoorComponent::occluder`; `DoorSystem` hides it while the door is fully
closed so the player never sees the missing room interior through the
opened doorway before the switch fires. A second flat black **gap shield**
quad (`CellGridDoorOccluderShield_*`, tracked via `DoorComponent::gapShield`)
sits 1 cm behind the closed leaf's back face on the same void side and is
visible exactly while the door is fully closed: spanning the leaf box plus
a 6 cm margin, it blacks out the leaf/frame clearance slits (0.02 m per
side, 0.05 m at the top) that would otherwise show the ungenerated void
(sky/terrain) around the closed door; its margin edges end up inside the
jamb boxes and wall thickness, so it never pokes out of the frame. Every
scene-switch door gets the
same open-front tunnel mesh (`CellGridDoorOccluderTunnel_*`, shared per
size and side) on the VOID side of the doorway: door-local +Z points
outward from the owning cell, where normal grids hide the missing room,
while exteriorOnly grids hide their missing interior inward, so
`CellGridSystem` passes `DoorBuildParams::occluderSide = -1` there (the
builder default is +1; standalone doors keep it). The tunnel depth covers
the fully-open leaf sweep, so the leaf stays visible whether it swings
towards or away from the player, and the side/top/bottom walls flared
6 cm into the frame — past the 0.02 m side / 0.05 m top leaf clearances,
so their edges sit inside the jamb boxes and no sight line slips between
the frame and the tunnel into the void — keep the darkness hole-free. The shared
`CellGridDoorOccluderBlack` material keeps lighting ENABLED with
all-black colours (diffuse/ambient/specular/emissive) — with lighting
disabled RTSS ignores material colours and renders the pass white.
New `CellGridComponent` fields (serialized in the scene JSON and exposed to
Lua): `doorsEnabled`, `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`,
@@ -745,10 +795,12 @@ rebuilds, scene switches and save/load (the save file's `globalState`
section). Ephemeral doors (empty `doorId`) never touch the store.
- **Defaults & restore**: `DoorBuilder::build()` calls
`DoorSystem::declareDoorDefaults(doorId, lockedByDefault)` (declares the
store defaults, returns the persisted open state) and snaps a door that
was left open straight to `openAngle` with its collider disabled — no
swing animation on load.
`DoorSystem::declareDoorDefaults(doorId, lockedByDefault,
sceneSwitchDoor)` (declares the store defaults, returns the persisted
open state) and snaps a door that was left open straight to `openAngle`
with its collider disabled — no swing animation on load. Scene-switch
doors (`sceneSwitchDoor = true`) always reopen closed instead and their
stored `isOpen` is reset, so a door left through is never ajar on return.
- **Write path**: `DoorSystem::update()` stores `isOpen` when a swing
completes.
- **Locked state**: `DoorSystem::isDoorLocked()` / `isDoorLockedById()` /
@@ -857,6 +909,47 @@ Test: `testNavMeshDoors` in the `--run-terrain-tests` suite (builds a
floor + wall + doorway with a real door entity headlessly; path through
the doorway, door-area marking, lock blocks, unlock restores).
### VehicleSystem (F10)
Drivable vehicles on top of `JPH::VehicleConstraint` +
`WheeledVehicleController` (raycast `VehicleCollisionTester`). An
entity with `VehicleComponent` + a dynamic `RigidBodyComponent` (box
collider) gets a vehicle constraint on its rigid body;
`VehicleSystem::prePhysicsUpdate()` feeds `inputForward` / `inputRight`
/ `inputBrake` / `inputHandBrake` into Jolt (with the Jolt sample's
brake-before-reverse rule) and `postPhysicsUpdate()` moves wheel visual
child nodes (created from `wheelMeshName`) to the constraint's wheel
transforms. Both hooks are called from `EditorApp` around the physics
step. Any number of vehicles per scene; removal of the component or
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
`position` (chassis-local), `radius`, `width`, `suspensionMinLength` /
`suspensionMaxLength` / `suspensionFrequency` / `suspensionDamping`,
`maxSteerAngleDeg` (0 = fixed), `driven`, `maxHandBrakeTorque`. Keep
`maxTorque` modest (the demo forklift uses 120; large values through
1st gear cause wheelies). The wrapper splits the differential
`mEngineTorqueRatio` evenly across driven wheels (Jolt asserts the
ratios sum to 1).
Gotchas found while landing this (demo-sokoban Phase 1):
- `EditorPhysicsSystem::buildCompoundShape()` places the rigid-body
entity's *own* collider at the compound origin (intra-entity offset
via `collider.offset` only); child colliders keep their local
transforms. It previously offset the own collider by the entity's
world position, stranding dynamic-body colliders at 2x their world
position (invisible near the origin, fatal far away — e.g. on the
streaming terrain).
- Headless runs need `EditorApp::setFixedDeltaTime(1/60)` (used by the
demo's test modes): sub-millisecond headless frames otherwise starve
the fixed-step accumulator and the soft suspension position-solve
misbehaves.
- `EditorPhysicsSystem::update()` clamps deltaTime to 0.1 s so the
post-load hitch cannot trigger huge catch-up steps.
### SceneScriptComponent & SceneScriptSystem
Attaches a Lua script to a scene or prefab entity. Fields:
+4
View File
@@ -20,6 +20,7 @@ set(EDITSCENE_SOURCES
systems/EditorUISystem.cpp
systems/SceneSerializer.cpp
systems/PhysicsSystem.cpp
systems/VehicleSystem.cpp
systems/BuoyancySystem.cpp
systems/EditorSunSystem.cpp
systems/EditorSkyboxSystem.cpp
@@ -105,6 +106,7 @@ set(EDITSCENE_SOURCES
ui/RenderableEditor.cpp
ui/PhysicsColliderEditor.cpp
ui/RigidBodyEditor.cpp
ui/VehicleEditor.cpp
ui/LightEditor.cpp
ui/CameraEditor.cpp
ui/LodEditor.cpp
@@ -187,6 +189,7 @@ set(EDITSCENE_SOURCES
components/TransformModule.cpp
components/RenderableModule.cpp
components/RigidBodyModule.cpp
components/VehicleModule.cpp
components/PhysicsColliderModule.cpp
components/PrefabInstanceModule.cpp
components/CharacterIdentityModule.cpp
@@ -1051,3 +1054,4 @@ add_subdirectory(demos/demo-character-controller)
add_subdirectory(demos/demo-scene-switching)
add_subdirectory(demos/demo-scene-switching-extra)
add_subdirectory(demos/demo-interior-exterior-dynamics)
add_subdirectory(demos/demo-sokoban)
+46 -20
View File
@@ -7,6 +7,7 @@
#include "GameMode.hpp"
#include <OgreMaterialManager.h>
#include "systems/EditorUISystem.hpp"
#include "systems/VehicleSystem.hpp"
#include "systems/PhysicsSystem.hpp"
#include "systems/BuoyancySystem.hpp"
#include "systems/EditorSunSystem.hpp"
@@ -427,6 +428,8 @@ void EditorApp::destroyEditorSystems()
m_skyboxSystem.reset();
m_sunSystem.reset();
m_buoyancySystem.reset();
/* Vehicles hold physics step listeners; tear down before physics. */
m_vehicleSystem.reset();
m_physicsSystem.reset();
/* Flush the RTShader generator cache before clearing materials. This
@@ -530,6 +533,10 @@ void EditorApp::setup()
if (m_uiSystem)
m_uiSystem->setPhysicsSystem(m_physicsSystem.get());
/* F10 vehicles (needs the physics wrapper). */
m_vehicleSystem = std::make_unique<VehicleSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
// Setup buoyancy system (requires physics system)
// Get the physics wrapper from the physics system
m_buoyancySystem = std::make_unique<BuoyancySystem>(
@@ -2109,6 +2116,14 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
{
bool paused = (m_gamePlayState == GamePlayState::Paused);
/* Headless test runs can pin the frame delta so physics and
* gameplay systems step deterministically (a headless frame takes
* ~1ms of wall time, which would otherwise feed tiny deltas to the
* fixed-step physics accumulator). */
Ogre::FrameEvent fixedEvt = evt;
if (m_fixedDeltaTime > 0.0f)
fixedEvt.timeSinceLastFrame = m_fixedDeltaTime;
/* A queued scene switch runs before anything else so systems
* below already see the new scene. */
processPendingSceneSwitch();
@@ -2162,14 +2177,14 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_gameMode == GameMode::Editor) {
// Update camera
if (m_camera) {
m_camera->update(evt.timeSinceLastFrame);
m_camera->update(fixedEvt.timeSinceLastFrame);
}
} else if (m_gameMode == GameMode::Game) {
if (m_gamePlayState == GamePlayState::Playing) {
m_playTime += evt.timeSinceLastFrame;
m_playTime += fixedEvt.timeSinceLastFrame;
if (m_playerControllerSystem) {
m_playerControllerSystem->update(
evt.timeSinceLastFrame);
fixedEvt.timeSinceLastFrame);
}
}
}
@@ -2185,13 +2200,13 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
/* --- Animation / procedural generation --- */
if (m_animationTreeSystem) {
m_animationTreeSystem->update(evt.timeSinceLastFrame);
m_animationTreeSystem->update(fixedEvt.timeSinceLastFrame);
if (m_behaviorTreeSystem)
m_behaviorTreeSystem->update(
evt.timeSinceLastFrame);
fixedEvt.timeSinceLastFrame);
}
if (m_pathFollowingSystem) {
m_pathFollowingSystem->update(evt.timeSinceLastFrame);
m_pathFollowingSystem->update(fixedEvt.timeSinceLastFrame);
}
if (m_proceduralMeshSystem) {
m_proceduralMeshSystem->update();
@@ -2199,7 +2214,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
/* --- Terrain update (before static world so navmesh can use terrain) --- */
if (m_terrainSystem) {
m_terrainSystem->update(evt.timeSinceLastFrame);
m_terrainSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Terrain prefab spawners (after terrain so spawn Y-snap
@@ -2223,47 +2238,47 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
/* --- NavMesh builds after static geometry is ready --- */
if (m_navMeshSystem) {
m_navMeshSystem->update(evt.timeSinceLastFrame);
m_navMeshSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Smart Object system (AI navigation to smart objects) --- */
if (m_smartObjectSystem) {
m_smartObjectSystem->update(evt.timeSinceLastFrame);
m_smartObjectSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- GOAP Planner system (plan generation) --- */
if (m_goapPlannerSystem) {
m_goapPlannerSystem->update(evt.timeSinceLastFrame);
m_goapPlannerSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- GOAP Runner system (plan execution) --- */
if (m_goapRunnerSystem) {
m_goapRunnerSystem->update(evt.timeSinceLastFrame);
m_goapRunnerSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Actuator system (player interaction prompts) --- */
if (m_actuatorSystem) {
m_actuatorSystem->update(evt.timeSinceLastFrame);
m_actuatorSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Door system (door swing animation) --- */
if (m_doorSystem) {
m_doorSystem->update(evt.timeSinceLastFrame);
m_doorSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Standalone doors (rebuild dirty door entities) --- */
if (m_standaloneDoorSystem) {
m_standaloneDoorSystem->update(evt.timeSinceLastFrame);
m_standaloneDoorSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Event Handler system (event-driven BTs) --- */
if (m_eventHandlerSystem) {
m_eventHandlerSystem->update(evt.timeSinceLastFrame);
m_eventHandlerSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Dynamic physics (characters after static world) --- */
if (m_characterSystem) {
m_characterSystem->update(evt.timeSinceLastFrame);
m_characterSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Buoyancy system (before physics so impulse is integrated) --- */
@@ -2296,7 +2311,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
m_buoyancySystem->setCameraPosition(cameraPos);
}
m_buoyancySystem->update(evt.timeSinceLastFrame);
m_buoyancySystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- Hair physics root sync (before physics step) --- */
@@ -2304,9 +2319,20 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_hairPhysicsSystem->prePhysicsUpdate();
}
/* --- F10 vehicle inputs (before the physics step) --- */
if (m_vehicleSystem) {
m_vehicleSystem->prePhysicsUpdate(
fixedEvt.timeSinceLastFrame);
}
/* --- Main physics step --- */
if (m_physicsSystem) {
m_physicsSystem->update(evt.timeSinceLastFrame);
m_physicsSystem->update(fixedEvt.timeSinceLastFrame);
}
/* --- F10 vehicle wheel visuals (after the physics step) --- */
if (m_vehicleSystem) {
m_vehicleSystem->postPhysicsUpdate();
}
/* --- Hair physics pose read-back (after physics step) --- */
@@ -2322,7 +2348,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
cam = m_sceneMgr->getCamera("PlayerCamera");
if (!cam && m_camera)
cam = m_camera->getCamera();
m_sunSystem->update(evt.timeSinceLastFrame, cam);
m_sunSystem->update(fixedEvt.timeSinceLastFrame, cam);
}
if (m_skyboxSystem) {
Ogre::Camera *cam = nullptr;
@@ -2338,7 +2364,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
cam = m_sceneMgr->getCamera("PlayerCamera");
if (!cam && m_camera)
cam = m_camera->getCamera();
m_waterPlaneSystem->update(evt.timeSinceLastFrame, cam);
m_waterPlaneSystem->update(fixedEvt.timeSinceLastFrame, cam);
}
if (m_lightSystem) {
m_lightSystem->update();
+19
View File
@@ -14,6 +14,7 @@
// Forward declarations
class EditorUISystem;
class VehicleSystem;
class EditorCamera;
class EditorPhysicsSystem;
class EditorLightSystem;
@@ -170,6 +171,10 @@ public:
void shutdownEditor();
void closeApp();
/* Pin the per-frame delta (seconds) for deterministic headless
* tests; 0 (default) uses the wall-clock delta. */
void setFixedDeltaTime(float dt) { m_fixedDeltaTime = dt; }
// OgreBites::InputListener overrides
bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override;
bool mousePressed(const OgreBites::MouseButtonEvent &evt) override;
@@ -319,6 +324,14 @@ public:
{
return m_playerControllerSystem.get();
}
VehicleSystem *getVehicleSystem() const
{
return m_vehicleSystem.get();
}
EditorPhysicsSystem *getEditorPhysicsSystem() const
{
return m_physicsSystem.get();
}
TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const
{
return m_terrainPrefabSpawnerSystem.get();
@@ -425,6 +438,8 @@ private:
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
/* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */
std::unique_ptr<VehicleSystem> m_vehicleSystem;
// State
uint16_t m_currentModifiers;
@@ -465,6 +480,10 @@ private:
int m_sceneSwitchCoverFrames = 0;
static const int SCENE_SWITCH_COVER_FRAMES = 45;
/* When > 0, frameRenderingQueued uses this fixed delta instead of
* the wall-clock delta (headless test determinism). */
float m_fixedDeltaTime = 0.0f;
void processPendingSceneSwitch();
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
+51 -14
View File
@@ -604,7 +604,10 @@ editor-validated-unique `doorId` field on standalone doors (F2). F6 consumes
isOpen)` when a swing **completes** (reaches 0 or `openAngle`).
- Door creation (`buildDoorEntities`, standalone builder) reads the store
and snaps `currentAngle`/`isOpen`/collider to the persisted state
instantly (no swing animation on load).
instantly (no swing animation on load) — except scene-switch doors,
which always reopen closed (`declareDoorDefaults(...,
sceneSwitchDoor = true)` resets their stored `isOpen`), so a door left
through is never ajar when the player returns.
- Survives scene switches: the store is a singleton and scene switch does
not touch it; a door re-created in a revisited scene restores its state.
@@ -767,12 +770,35 @@ switch via the toggle path). `DoorSystem` fires the switch — and the new
`door_scene_switch` event (params `path`/`target`/`door_id`) — only when
the leaf reaches `openAngle` (`setEditorApp()` wires the `EditorApp` call;
without it only the event fires, which is what the headless test uses).
The door builder creates the black occluder (shared unit-box mesh
`CellGridDoorOccluderBox` + unlit `CellGridDoorOccluderBlack` material),
childed to the grid node so it does not swing, sized from the closed-leaf
collider extents and pushed 2 cm behind the leaf plane; `DoorSystem`
hides it while fully closed. Tests: `cellgrid_door_test` test 10 (event
timing — nothing mid-swing, exactly once at full opening, close cancels).
The door builder creates the black occluder (open-front tunnel mesh
`CellGridDoorOccluderTunnel_*`, shared per doorway size and side, +
`CellGridDoorOccluderBlack` material), childed to the grid node so it
does not swing. Every scene-switch door gets the same tunnel on the VOID
side of the doorway — door-local +Z points outward from the owning cell
(normal grids hide the missing room there); `CellGridSystem` passes
`occluderSide = -1` for exteriorOnly grids, whose missing interior is
inward. The back panel sits behind the fully-open leaf sweep so the
swinging leaf stays visible for both swing directions, and
side/top/bottom walls flared 6 cm into the frame — past the 0.02 m side /
0.05 m top leaf clearances, so their edges sit inside the jamb boxes and
no sight line slips between the frame and the tunnel into the void — keep the darkness
hole-free; `DoorSystem` hides the occluder while fully closed. A second
flat black gap-shield quad (`CellGridDoorOccluderShield_*`, tracked via
`DoorComponent::gapShield`) sits 1 cm behind the closed leaf's back face
on the same void side and is visible exactly while the door is fully
closed: spanning the leaf box plus a 6 cm margin, it blacks out the
leaf/frame clearance slits (0.02 m per side, 0.05 m at the top) that
otherwise show the ungenerated void (sky/terrain) around the closed
door; its margin edges end up inside the jamb boxes and wall thickness,
so it never pokes out of the frame. The
occluder material keeps lighting enabled with all-black
colours — an unlit pass renders white under RTSS (FFPColour defaults the
output to white when no lighting SRS runs). Scene-switch doors never
restore the persisted open state: `declareDoorDefaults()` resets their
stored `isOpen`, so they are always closed again when the scene reloads
(before the loading cover lifts). Tests: `cellgrid_door_test` test 10
(event timing — nothing mid-swing, exactly once at full opening, close
cancels) plus the `declareDoorDefaults` scene-switch reset in test 8.
Demo: `demo-scene-switching-extra` scene A exit doorway `Z:0:0:15` is a
scene-switch door to scene B; `--headless --test-switch` does the A→B
leg through it and asserts the switch queues only at full opening (the
@@ -794,13 +820,24 @@ sub-millisecond while swings are real-time).
`EditorApp::switchScene(sceneSwitchPath, {targetEntityName:
sceneSwitchTarget})`. The existing loading cover hides the switch.
- **Black occluder:** `buildDoorEntities()` (and the F2 standalone builder)
additionally creates an unlit black box ("cube") entity childed to the
door entity, sized to the doorway opening, placed a few cm behind the
closed leaf plane, for doors with a non-empty `sceneSwitchPath`. Its
visibility follows the door state: hidden while fully closed, visible
while open/swinging (`RenderQueue`/visible flag toggled by `DoorSystem`
from `currentAngle`), so the player never sees the missing room interior
behind the opened doorway before the switch fires.
additionally creates a black occluder entity childed to the door entity,
for doors with a non-empty `sceneSwitchPath`. All of them get the same
open-front tunnel on the VOID side of the doorway
(`DoorBuildParams::occluderSide`: +1 = door-local +Z, outward from the
owning cell, the default; `CellGridSystem` passes -1 for exteriorOnly
grids, whose missing interior is inward). The tunnel depth covers the
fully-open leaf sweep, so the leaf stays visible whether it swings
towards or away from the player, and side/top/bottom walls close the
gaps around the frame.
Its visibility follows the door state: hidden while fully closed, visible
while open/swinging (visible flag toggled by `DoorSystem` from
`currentAngle`), so the player never sees the missing room interior
behind the opened doorway before the switch fires. A complementary flat
gap-shield quad just behind the closed leaf (leaf box + 6 cm margin,
covering the 0.02 m side / 0.05 m top clearances) is visible exactly
while the door is fully closed, blacking out the clearance slits. The
material is lit
with all-black colours (unlit passes render white under RTSS).
- The prompt stays "E Open" (never "E Close") for scene-switch doors, as
today.
+14 -4
View File
@@ -70,11 +70,21 @@ struct DoorComponent {
// reaches openAngle. Runtime only, set by ActuatorSystem.
bool sceneSwitchPending = false;
// F1: unlit black box covering the doorway of a scene-switch door,
// created by the door builder a few cm behind the closed leaf plane
// (child of the grid node, so it does NOT swing with the hinge).
// Hidden while the door is fully closed. Runtime only.
// F1: black open-front tunnel covering the doorway of a scene-switch
// door, created by the door builder on the VOID side of the doorway
// (door-local +Z outward from the owning cell; -Z for exteriorOnly
// grids); its depth covers the fully-open leaf sweep, so the leaf
// stays visible for both swing directions. Child of the grid node,
// so it does NOT swing with the hinge. Hidden while the door is
// fully closed. Runtime only.
Ogre::Entity *occluder = nullptr;
// F1: flat black gap-shield quad just behind the closed leaf of a
// scene-switch door (same void side as the tunnel), visible only
// while the door is fully closed - it blacks out the leaf/frame
// clearance slits (0.02 m sides, 0.05 m top) the tunnel, hidden
// while closed, does not cover. Runtime only.
Ogre::Entity *gapShield = nullptr;
};
#endif // EDITSCENE_DOOR_HPP
@@ -0,0 +1,76 @@
#ifndef EDITSCENE_VEHICLE_HPP
#define EDITSCENE_VEHICLE_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <Jolt/Jolt.h>
namespace JPH
{
class VehicleConstraint;
}
/**
* Vehicle wheel definition (F10, see demos/demo-sokoban/PLAN.md).
*
* Wheels come in axle pairs (0/1 = first axle, 2/3 = second, ...);
* engine torque is routed through a differential per pair whose wheels
* are both marked driven.
*/
struct VehicleWheel {
/* Suspension attachment point in chassis space. */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
float radius = 0.3f;
float width = 0.15f;
float suspensionMinLength = 0.1f;
float suspensionMaxLength = 0.4f;
float suspensionFrequency = 1.5f;
float suspensionDamping = 0.7f;
/* 0 = not steered (degrees). */
float maxSteerAngleDeg = 0.0f;
bool driven = false;
/* 0 = no handbrake on this wheel. */
float maxHandBrakeTorque = 0.0f;
};
/**
* Vehicle component.
*
* Turns an entity with a dynamic RigidBodyComponent (the chassis) into
* a drivable vehicle: VehicleSystem attaches a Jolt VehicleConstraint
* with a WheeledVehicleController to the chassis body and feeds the
* driver input fields below into it.
*
* Visuals: the chassis mesh comes from the entity's RenderableComponent
* (or Primitive); when wheelMeshName is set, VehicleSystem creates one
* child node per wheel and syncs it from the constraint state.
*/
struct VehicleComponent {
std::vector<VehicleWheel> wheels;
float maxTorque = 400.0f;
/* 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). */
Ogre::Vector3 seatOffset = Ogre::Vector3(0.0f, 1.0f, 0.0f);
/* 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. */
float inputForward = 0.0f;
float inputRight = 0.0f;
float inputBrake = 0.0f;
float inputHandBrake = 0.0f;
/* Runtime: the Jolt vehicle constraint on the chassis body. */
JPH::VehicleConstraint *constraint = nullptr;
bool constraintCreated = false;
};
#endif // EDITSCENE_VEHICLE_HPP
@@ -0,0 +1,48 @@
#include "Vehicle.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/VehicleEditor.hpp"
/* Default 4-wheel layout when the component is added in the editor:
* a small forklift-ish chassis (half extents ~0.7 x 0.4 x 1.2) with
* front axle driven and rear axle steered (real forklifts steer with
* the rear wheels; see demos/demo-sokoban/PLAN.md open questions). */
static VehicleComponent makeDefaultVehicle()
{
VehicleComponent v;
v.wheels.resize(4);
for (int i = 0; i < 4; i++) {
VehicleWheel &w = v.wheels[i];
bool left = (i % 2) == 0;
bool front = i < 2;
w.position = Ogre::Vector3(left ? 0.65f : -0.65f, -0.2f,
front ? 0.85f : -0.85f);
w.radius = 0.3f;
w.width = 0.2f;
w.suspensionMinLength = 0.05f;
w.suspensionMaxLength = 0.3f;
w.suspensionFrequency = 2.0f;
w.suspensionDamping = 0.8f;
w.driven = front;
w.maxSteerAngleDeg = front ? 0.0f : 35.0f;
w.maxHandBrakeTorque = front ? 0.0f : 200.0f;
}
return v;
}
// Register Vehicle component
REGISTER_COMPONENT_GROUP("Vehicle", "Physics", VehicleComponent,
VehicleEditor)
{
registry.registerComponent<VehicleComponent>(
"Vehicle", "Physics", std::make_unique<VehicleEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<VehicleComponent>())
e.set<VehicleComponent>(makeDefaultVehicle());
},
// Remover
[](flecs::entity e) {
if (e.has<VehicleComponent>())
e.remove<VehicleComponent>();
});
}
@@ -482,6 +482,69 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
}
break;
}
/* F1: every scene-switch door gets the void-side
* tunnel occluder (depth covers the leaf sweep,
* so both swing directions stay visible) plus the
* gap shield backing the leaf/frame clearance
* slits while the door is closed. */
{
const DoorComponent &d =
findDoorById(exitDoorId)
.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 exit door has no tunnel occluder";
break;
}
if (!d.gapShield ||
d.gapShield->getMesh()
->getName()
.find("CellGridDoorOccluderShield") !=
0) {
failed = true;
failReason =
"F1 exit door has no gap shield";
break;
}
/* The door is still closed here: the shield
* backs the slits, the tunnel stays hidden. */
if (!d.gapShield->isVisible() ||
d.occluder->isVisible()) {
failed = true;
failReason =
"F1 closed-door occluder visibility wrong";
break;
}
/* The tunnel walls must reach past the
* leaf/frame clearances (0.02 m sides,
* 0.05 m top) or the void shows between the
* frame and the black corridor while the
* door is open; both meshes are hinge-local,
* so the tunnel footprint must cover at
* least the gap shield's (6 cm margin). */
{
Ogre::Vector3 th =
d.occluder->getMesh()
->getBounds()
.getHalfSize();
Ogre::Vector3 sh =
d.gapShield->getMesh()
->getBounds()
.getHalfSize();
if (th.x < sh.x - 0.001f ||
th.y < sh.y - 0.001f) {
failed = true;
failReason =
"F1 tunnel walls inside the leaf/frame clearances";
break;
}
}
}
if (!requestDoorSwitch(exitDoorId))
break;
phase = 13;
@@ -608,6 +671,28 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
"after scene switch"
<< std::endl;
}
/* F1: the scene-switch exit door rebuilt with the
* scene must be closed again - scene-switch doors
* never restore the persisted open state. */
{
flecs::entity exitDoor =
findDoorById(exitDoorId);
if (!exitDoor.is_alive())
break;
const DoorComponent &d =
exitDoor.get<DoorComponent>();
if (d.isOpen || d.currentAngle != 0.0f ||
GlobalStateStore::getInstance().getBool(
"door." + exitDoorId + ".isOpen")) {
failed = true;
failReason =
"F1 exit door not closed after scene switch";
break;
}
std::cout << "[test] F1 exit door closed after "
"scene switch"
<< std::endl;
}
if (!failed) {
std::cout << "[test] arrived back in the interior "
"scene at arrival_a, camera faces the "
@@ -690,7 +775,10 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
* Z:0:0:0 is the way back. E on such a door swings the leaf open first;
* the scene switch fires only when the leaf reaches the open angle, and
* a black occluder box behind the doorway hides the missing half of the
* building while it swings. Both scenes carry their own player
* building while it swings; while the door is closed a flat black gap
* shield behind the leaf blacks out the leaf/frame clearance slits, so
* no sky/terrain bleeds through the closed door. Both scenes carry
* their own player
* controller, so each switch is a clean takeover (see
* EditorApp::performSceneSwitch); travel back and forth is endless.
*
@@ -712,7 +800,9 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
* persisted and restored across the scene switches) and
* both F1 scene-switch doors (interior -> exterior and
* back each fire only when the leaf is fully open,
* black occluder present), verifying the round-trip
* black occluder tunnel + closed-door gap shield
* present with the right closed-state visibility),
* verifying the round-trip
* arrival teleports (the exterior arrival check reads
* the expected Y from TerrainSystem::getHeightAt and
* polls while the streaming window loads and the
@@ -110,9 +110,9 @@
},
"transform": {
"position": {
"x": 4000.6,
"y": 9.9905,
"z": 3995.9
"x": 4000.60009765625,
"y": 9.990500450134277,
"z": 3995.89990234375
},
"rotation": {
"w": 0.0,
@@ -217,7 +217,7 @@
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_interior.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": true,
"doorSwingReversed": false,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
@@ -365,7 +365,7 @@
"transform": {
"position": {
"x": 4000.0,
"y": 10.0148,
"y": 10.014800071716309,
"z": 4000.0
},
"rotation": {
@@ -442,14 +442,14 @@
"spawnDistance": 100.0
},
"children": [],
"id": 495,
"id": 494,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 9.9039,
"y": 9.903900146484375,
"z": 3975.0
},
"rotation": {
@@ -467,7 +467,7 @@
},
{
"children": [],
"id": 496,
"id": 495,
"name": {
"name": "player"
},
@@ -517,7 +517,7 @@
},
{
"children": [],
"id": 497,
"id": 496,
"name": {
"name": "terrain"
},
@@ -525,7 +525,7 @@
"auxMaps": [],
"baseNoise": {
"amplitude": 15.0,
"frequency": 0.0003,
"frequency": 0.0003000000142492354,
"lacunarity": 2.0,
"octaves": 3,
"persistence": 0.5,
@@ -536,7 +536,7 @@
"detailNoise": {
"amplitude": 10.0,
"enabled": false,
"frequency": 0.006,
"frequency": 0.006000000052154064,
"lacunarity": 2.0,
"octaves": 4,
"persistence": 0.5,
@@ -576,12 +576,12 @@
"roadLodDistance": 200.0,
"roadMaterialName": "RoadMaterial",
"roadMeshTemplate": "road_segment.mesh",
"roadThickness": 0.3,
"roadThickness": 0.30000001192092896,
"roadVisibilityDistance": 1000.0,
"sidewalkEnabled": false,
"sidewalkHeight": 0.15,
"sidewalkHeight": 0.15000000596046448,
"sidewalkMeshTemplate": "",
"sidewalkThickness": 0.3,
"sidewalkThickness": 0.30000001192092896,
"sidewalkWidth": 1.5
},
"streamingEnabled": true,
@@ -611,89 +611,89 @@
},
{
"children": [],
"id": 498,
"id": 497,
"name": {
"name": "sky"
},
"skybox": {
"cloudiness": 0.0,
"dayBottomColor": [
0.6,
0.8,
0.6000000238418579,
0.800000011920929,
1.0
],
"dayTopColor": [
0.2,
0.20000000298023224,
0.5,
1.0
],
"enabled": true,
"moonSize": 0.03,
"moonSize": 0.029999999329447746,
"nightBottomColor": [
0.05,
0.05,
0.15
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"nightTopColor": [
0.0,
0.0,
0.05
0.05000000074505806
],
"size": 443.0,
"starsEnabled": false,
"sunSize": 0.05,
"sunSize": 0.05000000074505806,
"sunriseColor": [
1.0,
0.5,
0.2
0.20000000298023224
],
"sunsetColor": [
1.0,
0.3,
0.1
0.30000001192092896,
0.10000000149011612
]
},
"sun": {
"ambientDay": [
0.3,
0.3,
0.3
0.30000001192092896,
0.30000001192092896,
0.30000001192092896
],
"ambientNight": [
0.05,
0.05,
0.15
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"ambientSunrise": [
0.3,
0.2,
0.15
0.30000001192092896,
0.20000000298023224,
0.15000000596046448
],
"ambientSunset": [
0.25,
0.15,
0.1
0.15000000596046448,
0.10000000149011612
],
"castShadows": true,
"enabled": true,
"intensity": 1.79,
"intensity": 1.7899999618530273,
"moonColor": [
0.3,
0.3,
0.30000001192092896,
0.30000001192092896,
0.5
],
"moonSphereSize": 3.4,
"moonSphereSize": 3.4000000953674316,
"orbitTilt": 15.0,
"showMoonSphere": true,
"showSunSphere": true,
"sunColor": [
1.0,
0.95,
0.8
0.949999988079071,
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 6.226653575897217,
"timeSpeed": 0.13
"timeOfDay": 8.35942554473877,
"timeSpeed": 0.12999999523162842
},
"transform": {
"position": {
@@ -716,7 +716,7 @@
},
{
"children": [],
"id": 499,
"id": 498,
"name": {
"name": "water"
},
@@ -739,12 +739,12 @@
}
},
"waterPhysics": {
"defaultAngularDrag": 0.05,
"defaultAngularDrag": 0.05000000074505806,
"defaultBuoyancy": 1.0,
"defaultLinearDrag": 0.25,
"defaultSubmergedThreshold": 0.1,
"defaultSubmergedThreshold": 0.10000000149011612,
"enabled": true,
"gravity": 9.81,
"gravity": 9.8100004196167,
"waterDensity": 1000.0,
"waterSurfaceY": 6.0
},
@@ -752,18 +752,18 @@
"autoUpdateFromWaterPhysics": true,
"enabled": true,
"planeSize": 12000.0,
"reflectivity": 0.38,
"reflectivity": 0.3799999952316284,
"renderTextureSize": 512,
"tiling": 0.012,
"tiling": 0.012000000104308128,
"waterColor": [
0.0,
0.3,
0.30000001192092896,
0.5,
0.8
0.800000011920929
],
"waterSurfaceY": 6.0,
"waveScale": 0.031,
"waveSpeed": 0.98
"waveScale": 0.03099999949336052,
"waveSpeed": 0.9800000190734863
}
}
],
@@ -374,6 +374,24 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
}
break;
}
/* F1: every scene-switch door gets the void-side
* tunnel occluder (depth covers the leaf sweep,
* so both swing directions stay visible). */
{
const DoorComponent &d =
findDoorById(exitDoorId)
.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 exit door has no tunnel occluder";
break;
}
}
if (!requestDoorSwitch(exitDoorId))
break;
phase = 13;
@@ -390,6 +408,31 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
case 1:
if (!entityExists("x1"))
break;
/* F1: the return door swings AWAY from the player
* (doorSwingReversed in demo_scene_b.json); the
* void-side tunnel occluder covers the full leaf
* sweep, so the swinging leaf stays visible. */
{
flecs::entity returnDoor =
findDoorById(returnDoorId);
if (returnDoor.is_alive()) {
const DoorComponent &d =
returnDoor.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 swing-away door has no tunnel occluder";
break;
}
std::cout << "[test] F1 swing-away door "
"has tunnel occluder"
<< std::endl;
}
}
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
@@ -447,6 +490,28 @@ struct SceneSwitchTestListener : public Ogre::FrameListener {
"after scene switch"
<< std::endl;
}
/* F1: the scene-switch exit door rebuilt with the
* scene must be closed again - scene-switch doors
* never restore the persisted open state. */
{
flecs::entity exitDoor =
findDoorById(exitDoorId);
if (!exitDoor.is_alive())
break;
const DoorComponent &d =
exitDoor.get<DoorComponent>();
if (d.isOpen || d.currentAngle != 0.0f ||
GlobalStateStore::getInstance().getBool(
"door." + exitDoorId + ".isOpen")) {
failed = true;
failReason =
"F1 exit door not closed after scene switch";
break;
}
std::cout << "[test] F1 exit door closed after "
"scene switch"
<< std::endl;
}
if (!failed) {
std::cout << "[test] arrived back in scene A "
"at arrival_a, camera faces the "
@@ -0,0 +1,125 @@
# ---------------------------------------------------------------------------
# demo-sokoban forklift sokoban activity demo (vehicle + quest HUD)
# ---------------------------------------------------------------------------
# Starts as an exact copy of demos/demo-interior-exterior-dynamics (same
# scene-switch-door setup, same streaming-terrain/sky/water exterior at
# world center ~(4000, 4000)); the sokoban activity (drivable forklift,
# pushable crates, target pads, completion HUD) is added to
# demo_scene_exterior.json step by step - see PLAN.md in this directory.
#
# The executable is self-contained in this build directory: a POST_BUILD
# step (stage_runtime.cmake) copies resources.cfg, the
# character prefab (prefabs/char_2.json, written by a previous editor/game
# run) and any runtime config JSONs here, and symlinks both demo scenes
# (demo_scene_interior.json / demo_scene_exterior.json, so scene edits in
# the source tree are visible without a rebuild) plus the big pre-staged
# runtime directories (resources/, characters/, lua-scripts/) from the
# editScene binary directory; the terrain heightmap is staged separately
# by configure_file (see below) so source changes re-copy it on the next
# build. Run it from here:
# cd <build>/src/features/editScene/demos/demo-sokoban
# ./demoSokoban
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
# --- Embedded project configuration (F8 release binary) ---------------
# Read project.json at configure time and embed its parameters into the
# binary via a generated project.h, so the release binary is attached to
# its project directory without needing --project. Editing project.json
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${PROJECT_JSON_PATH}")
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
if(PROJECT_GAME_MODE)
set(PROJECT_GAME_MODE_VALUE 1)
else()
set(PROJECT_GAME_MODE_VALUE 0)
endif()
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
# Terrain heightmap for the exterior scene's terrain entity (terrainId
# 4242424300000001, heightmapFile heightmap.bin TerrainSystem resolves it
# as heightmaps/<terrainId>/<heightmapFile> relative to the CWD). Generated
# in the source tree by gen_heightmap.py. The terrain runs in streaming
# mode (base heights come from baseNoise, not the heightmap), but the file
# stays staged so flipping streamingEnabled off keeps working. A
# configure_file COPYONLY copy is used on purpose: it registers a configure
# dependency on the source file, so regenerating heightmap.bin re-copies it
# into the staging directory on the next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
add_executable(demoSokoban
demo_main.cpp
${DEMO_SOURCES}
)
target_compile_definitions(demoSokoban
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
add_dependencies(demoSokoban morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoSokoban PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoSokoban
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoSokoban PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoSokoban POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-sokoban standalone runtime"
)
@@ -0,0 +1,362 @@
# demo-sokoban — Implementation Plan
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
yard, and completing the layout registers a completed "quest" shown in a
reusable HUD status display.
This demo starts as an exact copy of `demo-interior-exterior-dynamics`
(scene-switch doors interior <-> exterior, streaming terrain archipelago,
sky, water). All sokoban content is added to the exterior scene near the
existing content site at world ~(4000, 10, 4000).
## Design decisions (agreed with the user)
- **Quest**: no full quest system yet. Completion is recorded in
`GlobalStateStore` (per-instance keys `quest.<id>.completed = true`),
a `quest_completed` event is sent on the `EventBus`, and a new reusable
HUD status display (top-right corner) shows progress and the
completion banner. The HUD display is designed to be reused by a
future real quest system.
- **Vehicle**: real `JPH::VehicleConstraint` with
`WheeledVehicleController` — not a kinematic arcade hack.
- **Rules**: classic multi-crate sokoban field — several crates, the
same number of target pads, walls around the yard, completion when
every pad is covered. Crates are free dynamic bodies pushed by the
forklift (no grid snapping of motion; the *layout* is grid-aligned).
- **Visuals**: new Blender assets (forklift, crate, target pad)
exported through the existing asset pipeline — no procedural
primitives for the final look.
- Should be implemented in a way it is easy to set up in other demo
- It should be possible to play multiple instances of Sokoban in the same demo using the same or different forklifts.
- Should use Lua APIs where appropriate (for Sokoban logic at least).
- Jolt examples can be a reference for vehicle implementation.
### Consequences of these decisions (architecture constraints)
- **Engine-level, not demo-level**: all new components/systems/Lua APIs
live in `src/features/editScene` (`components/`, `systems/`, `lua/`,
`ui/`) so any demo or scene can use them. `demo-sokoban` itself only
contributes scene JSON data, assets and headless test listeners in
`demo_main.cpp` — no game logic in the demo binary.
- **Instance-based sokoban**: one yard = one game instance with its own
id, crate list, pad list and `quest.<id>.*` global-state keys. Any
number of yards can coexist in a scene; forklifts and crates are not
bound to a yard — anything that pushes a crate onto a pad counts.
- **Lua runs the rules**: C++ provides per-frame physics-adjacent
detection and emits events; the sokoban rules (what counts as
completion, HUD text, rewards, reset) live in a shared Lua module.
Note the existing Lua integration is execute-once + event-driven
(`SceneScriptSystem`, `EventBus`) with *no per-frame Lua hook* — so
per-frame detection stays in C++ and Lua reacts to events; no
per-frame Lua polling is added.
## Existing pieces we build on
- `physics/physics.h` (`JoltPhysicsWrapper`): body creation (incl.
world-space `RVec3` overloads), sensors, per-body contact listeners,
raycasts, `getPhysicsSystem()` access to the raw `JPH::PhysicsSystem`.
- `components/RigidBody.hpp` + `components/PhysicsCollider.hpp` +
`systems/PhysicsSystem.cpp`: static/dynamic/kinematic bodies from
scene JSON (`rigidBody`, `collider` keys), physics->node sync for
dynamic bodies. The crate needs nothing new.
- `components/Actuator.hpp` + `systems/ActuatorSystem.cpp`: E-key
interaction prompts; actions run behavior trees from the scene's
top-level `actionDatabase`, including `luaTask` nodes — enough to
emit a `vehicle_enter` event without new BT node types.
- `systems/PlayerControllerSystem.cpp` + `GameInputState`
(EditorApp.hpp): game-mode input handling and TPS camera boom to
model the vehicle controller/camera on.
- `systems/GlobalStateStore.*` (F9): persistent key/value store,
survives `switchScene()`, C++/Lua API.
- `systems/EventBus.*`: `send`/`subscribe` events, used by the door
contract and Lua (`ecs.send_event` / `ecs.subscribe_event`).
- `components/SceneScript.hpp` + inline scene scripts: per-scene Lua
glue.
- Component registration pattern: `components/FooModule.cpp` with
`REGISTER_COMPONENT_GROUP` + `ui/FooEditor.hpp` (see
`components/RigidBodyModule.cpp`), plus serialization in
`systems/SceneSerializer.cpp`.
- Demo scaffolding: this directory (CMake target, `stage_runtime.cmake`,
embedded `project.json`, symlinked scenes, `--headless
--exit-after-first-frame` smoke run, `--test-switch` end-to-end
listener pattern in `demo_main.cpp`).
- Feature doc convention: `GameFeatures202609.md` (F0-F9 used; this
work adds F10/F11/F12).
- Jolt vehicle reference implementations, checked out locally:
`/media/slapin/library/ogre3/jolt/Samples/Tests/Vehicle/`
(`VehicleConstraintTest`, `VehicleTest`, `VehicleSixDOFTest`) — the
same Jolt version the SDK is built from (`Jolt/Jolt.h` headers in the
ogre-sdk include tree).
## Pieces that do not exist yet (to build)
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.
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.
4. Sokoban logic: no crate/target/game-state tracking. Split into a
generic C++ zone-detection piece (reusable by future activities) and
the sokoban rules in Lua.
5. Assets: no forklift/crate/pad meshes.
6. Lua surface: no `ecs.hud.*` bindings; the HUD status display and its
Lua API are new.
## Phases
Each phase ends in a buildable, runnable demo; verification commands
assume the build tree (e.g. `build-vscode`) is configured.
### Phase 0 — Demo skeleton (this step)
Copy of `demo-interior-exterior-dynamics` with target `demoSokoban`,
`project.json` appName `demo-sokoban`, registered via
`add_subdirectory(demos/demo-sokoban)` in
`src/features/editScene/CMakeLists.txt`.
Verify:
```bash
cmake --build build-vscode --target demoSokoban -j4
cd build-vscode/src/features/editScene/demos/demo-sokoban
./demoSokoban --headless --exit-after-first-frame
./demoSokoban --headless --test-switch # inherited scene contract
```
### Phase 1 — Vehicle physics foundation (F10) — DONE
Status: complete; `--test-vehicle` passes on both the flat-floor scene
(`demo_scene_vehicletest.json`) and the streaming-terrain exterior scene.
What landed:
- `physics/physics.h/.cpp`: `JoltPhysicsWrapper::createVehicle` /
`destroyVehicle` / `setVehicleInput` / `getVehicleForwardSpeed` /
`getWheelWorldTransform` on top of `JPH::VehicleConstraint` +
`WheeledVehicleController` + a raycast `VehicleCollisionTester`;
the differential `mEngineTorqueRatio` is split evenly (1/N per driven
wheel) so 4WD configs satisfy Jolt's sum-of-torque-ratios assert.
- `components/Vehicle.hpp` + `components/VehicleModule.cpp` +
`ui/VehicleEditor.{hpp,cpp}`: `VehicleComponent` (wheel list with
offset/radius/width/suspension/steer/driven/handbrake per wheel,
`maxTorque`, `maxPitchRollAngleDeg`, `seatOffset`, `wheelMeshName`),
registered, editable in the editor UI and serialized as the `vehicle`
scene key (see `SceneSerializer`).
- `systems/VehicleSystem.{hpp,cpp}`: creates/destroys the constraint
from `VehicleComponent` + the entity's existing `RigidBodyComponent`
body, applies driver input (with the Jolt sample's
brake-before-reverse rule) in `prePhysicsUpdate`, and moves wheel
visual child nodes from constraint state in `postPhysicsUpdate`
(both wired into `EditorApp` around the physics step).
- Forklift placeholder entity `forklift1` (id 500) at (4012, 10.8, 3960)
in `demo_scene_exterior.json` and (0, 1.5, 0) in the new flat-floor
regression scene `demo_scene_vehicletest.json` (symlinked into the
build dir by `stage_runtime.cmake` like the two main scenes).
Tuning: `maxTorque` 120 (500 Nm through 1st gear produced ~15 kN and
wheelies/backflips), front wheels driven, rear wheels steer 35 deg +
handbrake (rear-steer like a real forklift — resolves open question
4). Drives straight and stable: 0 -> ~8 m/s in 7 s on flat ground.
- `--test-vehicle` in `demo_main.cpp` (`VehicleTestListener`):
waits for constraint creation, waits for the chassis to settle
upright on the terrain (a grounding watchdog re-teleports the chassis
to its authored pose while the streaming page colliders are still
being built — kept deliberately, it guards the collider race), drives
forward 180 frames asserting progress and uprightness, then brakes to
a full stop; prints `[test] PASS`. Note: the process then dies with
a known pre-existing teardown segfault (WaterPlane RTT viewport
destruction, also present in the base demo) that masks the exit code
with 139 — judge by the `[test] PASS` line in stdout.
- Headless determinism: headless frames are ~1.3 ms wall time, which
starved the fixed-step accumulator and made the soft suspension
position-solve misbehave; `EditorApp::setFixedDeltaTime(float)` (used
by all headless test modes in this demo) pins
`evt.timeSinceLastFrame` to 1/60 so physics sees real steps.
`EditorPhysicsSystem::update()` also clamps deltaTime > 0.1 to 0.1 so
the post-load hitch cannot trigger huge catch-up steps.
- Bug fix in `systems/PhysicsSystem.cpp` (`buildCompoundShape`): the
rigid-body entity's *own* collider shape was wrapped in a
`RotatedTranslatedShape` offset by the entity's world position while
the body was also created at that position — the collider ended up at
2x the world position, so any dynamic body far from the origin fell
through the terrain (invisible near the origin, which is why it went
unnoticed; wheel raycasts still hit the terrain, which made it look
like a convex-vs-mesh narrowphase failure — a standalone Jolt repro
proved the page `MeshShape` itself is fine). The own-collider is now
placed at the compound origin; child colliders keep their local
offsets.
- Debug scene variants used during the hunt
(`demo_scene_exterior_nowater/_box/_min/_nostream.json`) were
deleted; coverage is the two `--test-vehicle` scenes.
Verify:
```bash
cmake --build build-vscode --target demoSokoban -j4
cd build-vscode/src/features/editScene/demos/demo-sokoban
./demoSokoban --headless --test-vehicle # terrain scene
./demoSokoban --headless --test-vehicle demo_scene_vehicletest.json # flat floor
./demoSokoban --headless --test-switch # inherited contract
./demoSokoban --headless --exit-after-first-frame # smoke
```
### Phase 2 — Blender assets
- `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.
Verify: run the demo with `--force-pos` + `--screenshot` (see the base
demo's debug capture flags) and inspect the render.
### Phase 3 — Enter/exit and vehicle controller (F10)
- `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.
Verify: `--test-vehicle-drive` headless: emit enter, drive, exit,
assert character ends up next to the moved forklift.
### Phase 4 — Crate (F11)
- `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).
- 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).
Verify: headless test applies an impulse through the wrapper and
asserts the crate slides and settles.
### Phase 5 — Sokoban yard layout + target zones (F11)
- 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.
Verify: screenshot of the yard from `--force-pos` above the site.
### Phase 6 — Zone detection (C++) + sokoban rules (Lua) (F11/F12)
C++ side (per-frame detection, engine-level, multi-instance):
- `systems/ZoneSystem.{hpp,cpp}`: per-frame XZ-overlap + nearly-at-rest
check of `PushableComponent` entities against `TargetZoneComponent`
zones; emits `EventBus` events `zone_entered` / `zone_left`
(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.
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.
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.
### Phase 7 — HUD status display + Lua bindings (F12)
- `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.
- `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`.
Verify: screenshot showing the status line and the completion banner.
### Phase 8 — Docs and cleanup
- Update `src/features/editScene/AGENTS.md`: demo entry (build/run/
controls/test flags), new systems (Vehicle, VehicleController, Zone,
GameHud), components (Vehicle, Pushable, TargetZone) and the
`ecs.hud.*` Lua API.
- Update root `AGENTS.md`: build target/output for `demoSokoban`.
- `GameFeatures202609.md`: F10 vehicle concept + forklift, F11 sokoban
activity (zones + Lua rules), F12 HUD status display.
- `lua-examples/sokoban_example.lua` showing a minimal yard setup
(zones + `sokoban.new`) and a `quest_completed` subscription.
## Open questions (to resolve as phases start)
1. Yard ground: terrain compliance flattening vs static concrete pad
platform (Phase 5 experiment decides).
2. Forklift forks: decorative (push-only, classic sokoban) or
functional lifting (adds grab/constraint mechanics — big extra;
assume decorative for now).
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?
(Resolved in Phase 1: rear axle steers 35 deg, front wheels driven.)
5. Exit placement rule when the forklift is boxed in.
6. Whether crate positions persist across scene switches/saves
(default: no — the layout resets).
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.
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
@@ -0,0 +1,906 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 488,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 4000.60009765625,
"y": 9.990500450134277,
"z": 3995.89990234375
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [
{
"cellGrid": {
"ceilingRectName": "",
"cellHeight": 4.0,
"cellSize": 2.0,
"cells": [
{
"flags": 37757955,
"x": -1,
"y": 0,
"z": 0
},
{
"flags": 2097667,
"x": 0,
"y": 0,
"z": 0
},
{
"flags": 41953283,
"x": 1,
"y": 0,
"z": 0
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 1
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 1
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 1
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 2
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 2
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 2
},
{
"flags": 20976643,
"x": -1,
"y": 0,
"z": 3
},
{
"flags": 16781315,
"x": 0,
"y": 0,
"z": 3
},
{
"flags": 25171971,
"x": 1,
"y": 0,
"z": 3
}
],
"depth": 10,
"doorActionName": "",
"doorConfigs": {},
"doorMeshName": "",
"doorOpenAngle": 100.0,
"doorOpenSpeed": 180.0,
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_interior.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": false,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
"extWallRectName": "",
"extWindowFrameRectName": "",
"floorRectName": "",
"friction": 0.5,
"furnitureCells": [],
"generationMode": "exteriorOnly",
"generationScript": "",
"glassColor": [
0.4000000059604645,
0.6000000238418579,
0.800000011920929,
0.3499999940395355
],
"glassMaterialName": "",
"glassReflectivity": 0.800000011920929,
"gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78",
"height": 1,
"intDoorFrameRectName": "",
"intWallRectName": "",
"intWindowFrameRectName": "",
"roofSideRectName": "",
"roofTopRectName": "",
"width": 10
},
"children": [
{
"children": [],
"clearArea": {
"clearCells": true,
"clearFurniture": true,
"clearRoofs": false,
"clearRooms": false,
"maxX": 10,
"maxY": 1,
"maxZ": 10,
"minX": -10,
"minY": 0,
"minZ": -10
},
"id": 491,
"name": {
"name": "c1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 492,
"name": {
"name": "r1"
},
"room": {
"connectedRoomIds": [],
"createCeiling": true,
"createFloor": true,
"createInteriorWalls": true,
"createWindows": true,
"exits": [
true,
false,
false,
false
],
"fillRoomWithFurniture": false,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
"maxY": 1,
"maxZ": 4,
"minX": -1,
"minY": 0,
"minZ": 0,
"persistentId": "room_1788689581735260047_499",
"roomType": "",
"tags": []
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 490,
"name": {
"name": "w1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 489,
"name": {
"name": "x1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.014800071716309,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 493,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
"diffuseColor": {
"a": 1.0,
"b": 1.0,
"g": 1.0,
"r": 1.0
},
"direction": {
"x": 0.30000001192092896,
"y": -1.0,
"z": 0.20000000298023224
},
"intensity": 1.0,
"lightType": "directional",
"linearAttenuation": 0.0,
"quadraticAttenuation": 0.0,
"range": 100.0,
"specularColor": {
"a": 1.0,
"b": 0.5,
"g": 0.5,
"r": 0.5
},
"spotlightFalloff": 1.0,
"spotlightInnerAngle": 30.0,
"spotlightOuterAngle": 45.0
},
"name": {
"name": "demo_light"
},
"transform": {
"position": {
"x": 4000.0,
"y": 20.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 494,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 9.903900146484375,
"z": 3975.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 495,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 496,
"name": {
"name": "terrain"
},
"terrain": {
"auxMaps": [],
"baseNoise": {
"amplitude": 15.0,
"frequency": 0.0003000000142492354,
"lacunarity": 2.0,
"octaves": 3,
"persistence": 0.5,
"seed": 55
},
"blendMapSize": 1024,
"compositeMapDistance": 300.0,
"detailNoise": {
"amplitude": 10.0,
"enabled": false,
"frequency": 0.006000000052154064,
"lacunarity": 2.0,
"octaves": 4,
"persistence": 0.5,
"seed": 26368
},
"enabled": true,
"farClipDistance": 6000.0,
"fogEnabled": true,
"fogEnd": 5500.0,
"fogStart": 2500.0,
"heightmapFile": "heightmap.bin",
"heightmapSize": 256,
"layers": [
{
"diffuseTexture": "Ground23_col.jpg",
"name": "Base",
"normalTexture": "Ground23_normheight.dds",
"worldSize": 100.0
},
{
"diffuseTexture": "Ground37_diffspec.dds",
"name": "Layer 1",
"normalTexture": "Ground37_normheight.dds",
"worldSize": 100.0
}
],
"maxBatchSize": 65,
"maxPixelError": 1.0,
"minBatchSize": 17,
"pageHoldRadius": 3,
"pageLoadRadius": 2,
"roadConfig": {
"laneWidth": 3.0,
"lanesPerDirection": 1,
"prefabDespawnDistance": 250.0,
"prefabSpawnDistance": 150.0,
"roadLodDistance": 200.0,
"roadMaterialName": "RoadMaterial",
"roadMeshTemplate": "road_segment.mesh",
"roadThickness": 0.30000001192092896,
"roadVisibilityDistance": 1000.0,
"sidewalkEnabled": false,
"sidewalkHeight": 0.15000000596046448,
"sidewalkMeshTemplate": "",
"sidewalkThickness": 0.30000001192092896,
"sidewalkWidth": 1.5
},
"streamingEnabled": true,
"terrainId": 4242424300000001,
"terrainSize": 65,
"worldSize": 2000.0,
"worldSizeUnits": 10000.0
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 497,
"name": {
"name": "sky"
},
"skybox": {
"cloudiness": 0.0,
"dayBottomColor": [
0.6000000238418579,
0.800000011920929,
1.0
],
"dayTopColor": [
0.20000000298023224,
0.5,
1.0
],
"enabled": true,
"moonSize": 0.029999999329447746,
"nightBottomColor": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"nightTopColor": [
0.0,
0.0,
0.05000000074505806
],
"size": 443.0,
"starsEnabled": false,
"sunSize": 0.05000000074505806,
"sunriseColor": [
1.0,
0.5,
0.20000000298023224
],
"sunsetColor": [
1.0,
0.30000001192092896,
0.10000000149011612
]
},
"sun": {
"ambientDay": [
0.30000001192092896,
0.30000001192092896,
0.30000001192092896
],
"ambientNight": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"ambientSunrise": [
0.30000001192092896,
0.20000000298023224,
0.15000000596046448
],
"ambientSunset": [
0.25,
0.15000000596046448,
0.10000000149011612
],
"castShadows": true,
"enabled": true,
"intensity": 1.7899999618530273,
"moonColor": [
0.30000001192092896,
0.30000001192092896,
0.5
],
"moonSphereSize": 3.4000000953674316,
"orbitTilt": 15.0,
"showMoonSphere": true,
"showSunSphere": true,
"sunColor": [
1.0,
0.949999988079071,
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 8.35942554473877,
"timeSpeed": 0.12999999523162842
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 498,
"name": {
"name": "water"
},
"transform": {
"position": {
"x": 4000.0,
"y": 0.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"waterPhysics": {
"defaultAngularDrag": 0.05000000074505806,
"defaultBuoyancy": 1.0,
"defaultLinearDrag": 0.25,
"defaultSubmergedThreshold": 0.10000000149011612,
"enabled": true,
"gravity": 9.8100004196167,
"waterDensity": 1000.0,
"waterSurfaceY": 6.0
},
"waterPlane": {
"autoUpdateFromWaterPhysics": true,
"enabled": true,
"planeSize": 12000.0,
"reflectivity": 0.3799999952316284,
"renderTextureSize": 512,
"tiling": 0.012000000104308128,
"waterColor": [
0.0,
0.30000001192092896,
0.5,
0.800000011920929
],
"waterSurfaceY": 6.0,
"waveScale": 0.03099999949336052,
"waveSpeed": 0.9800000190734863
}
},
{
"children": [],
"id": 500,
"name": {
"name": "forklift1"
},
"transform": {
"position": {
"x": 4012.0,
"y": 10.8,
"z": 3960.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 0.7,
"y": 0.45,
"z": 1.1
}
},
"renderable": {
"meshName": "Cube.mesh",
"visible": true
},
"collider": {
"shapeType": "box",
"parameters": {
"x": 0.7,
"y": 0.3,
"z": 1.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": "dynamic",
"mass": 1500.0,
"friction": 0.8,
"restitution": 0.0,
"isSensor": false,
"enabled": true
},
"vehicle": {
"maxTorque": 120.0,
"maxPitchRollAngleDeg": 60.0,
"seatOffset": {
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"wheelMeshName": "",
"wheels": [
{
"position": {
"x": 0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": 0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
}
]
}
}
],
"version": "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,460 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 4294967793,
"name": {
"name": "arrival_a"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 24.0
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 4294967794,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
"diffuseColor": {
"a": 1.0,
"b": 1.0,
"g": 1.0,
"r": 1.0
},
"direction": {
"x": 0.30000001192092896,
"y": -1.0,
"z": 0.20000000298023224
},
"intensity": 1.0,
"lightType": "directional",
"linearAttenuation": 0.0,
"quadraticAttenuation": 0.0,
"range": 100.0,
"specularColor": {
"a": 1.0,
"b": 0.5,
"g": 0.5,
"r": 0.5
},
"spotlightFalloff": 1.0,
"spotlightInnerAngle": 30.0,
"spotlightOuterAngle": 45.0
},
"name": {
"name": "demo_light"
},
"transform": {
"position": {
"x": 0.0,
"y": 10.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"collider": {
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": -0.10000000149011612,
"z": 0.0
},
"parameters": {
"x": 30.0,
"y": 0.10000000149011612,
"z": 30.0
},
"radius": 0.5,
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"shapeType": "box"
},
"id": 4294967792,
"name": {
"name": "demo_floor"
},
"renderable": {
"meshName": "DemoFloorPlaneInterior",
"visible": true
},
"rigidBody": {
"bodyType": "static",
"enabled": true,
"friction": 0.800000011920929,
"isSensor": false,
"mass": 1.0,
"restitution": 0.0
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 4294967790,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.10288965702056885,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 4294967789,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 500,
"name": {
"name": "forklift1"
},
"transform": {
"position": {
"x": 0.0,
"y": 1.5,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 0.7,
"y": 0.45,
"z": 1.1
}
},
"renderable": {
"meshName": "Cube.mesh",
"visible": true
},
"collider": {
"shapeType": "box",
"parameters": {
"x": 0.7,
"y": 0.3,
"z": 1.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": "dynamic",
"mass": 1500.0,
"friction": 0.8,
"restitution": 0.0,
"isSensor": false,
"enabled": true
},
"vehicle": {
"maxTorque": 120.0,
"maxPitchRollAngleDeg": 60.0,
"seatOffset": {
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"wheelMeshName": "",
"wheels": [
{
"position": {
"x": 0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": 0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
}
]
}
}
],
"version": "1.0"
}
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
streaming mode, where base heights come from the terrain's baseNoise
(FastNoiseLite) and this file is NOT sampled it is only staged next to
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
finds it. This script documents the historical non-streaming layout and
is kept as the generator of record for that staged file.
Legacy description (non-streaming mode):
The exterior scene's terrain entity (terrainId 4242424300000001,
non-streaming, worldSize 500 per page) loads its base heights from
heightmaps/4242424300000001/heightmap.bin via
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
then res*res little-endian float32 heights.
Height profile: a flat disc at y=0 around the origin (the playable area
with the building shell and the scene-switch arrival point), sloping
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
as a lake around the centre.
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
loads a fixed 3x3 page grid, so the heightmap covers the physical world
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
the height at physical coordinate (-ws + i * 3*ws / res). Physical
space maps to visual (scene) space by a half-page shift on X and a
per-page Z mirror; this script inverts that mapping so every texel gets
the height of the *visual* position it will be rendered at, then
verifies the round trip by re-sampling the generated grid the same
bilinear way the engine does.
"""
import math
import struct
import sys
RES = 256 # must match "heightmapSize" in the scene JSON
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
EDGE_RADIUS = 160.0 # slope reaches full depth here
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
def profile(r):
"""Visual height at distance r from the origin."""
if r <= FLAT_RADIUS:
return 0.0
if r >= EDGE_RADIUS:
return DEPTH
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
t = t * t * (3.0 - 2.0 * t) # smoothstep
return DEPTH * t
def physical_to_visual(px, pz):
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
vx = px - WORLD_SIZE * 0.5
page = math.floor(pz / WORLD_SIZE)
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
return vx, vz
def visual_to_physical(vx, vz):
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
px = vx + WORLD_SIZE * 0.5
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
return px, pz
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
span = 3.0 * WORLD_SIZE
origin = -WORLD_SIZE
grid = []
for iz in range(RES):
row = []
pz = origin + iz * span / RES
for ix in range(RES):
px = origin + ix * span / RES
vx, vz = physical_to_visual(px, pz)
row.append(profile(math.hypot(vx, vz)))
grid.append(row)
with open(out_path, "wb") as f:
f.write(struct.pack("<I", RES))
for row in grid:
f.write(struct.pack("<%df" % RES, *row))
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
# (bilinear, physical coords) at a few visual positions.
def sample(vx, vz):
px, pz = visual_to_physical(vx, vz)
fx = (px - origin) / span * RES
fz = (pz - origin) / span * RES
x0 = max(0, min(int(math.floor(fx)), RES - 1))
z0 = max(0, min(int(math.floor(fz)), RES - 1))
x1 = max(0, min(x0 + 1, RES - 1))
z1 = max(0, min(z0 + 1, RES - 1))
tx = fx - int(math.floor(fx))
tz = fz - int(math.floor(fz))
h00 = grid[z0][x0]
h10 = grid[z0][x1]
h01 = grid[z1][x0]
h11 = grid[z1][x1]
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
(1 - tx) * tz * h01 + tx * tz * h11)
checks = [
("arrival (0,24)", 0.0, 24.0),
("origin (0,0)", 0.0, 0.0),
("building corner (4,30)", 4.0, 30.0),
("flat edge (55,0)", 55.0, 0.0),
("mid slope (110,0)", 110.0, 0.0),
("lake (200,0)", 200.0, 0.0),
("lake (-200,200)", -200.0, 200.0),
]
ok = True
for label, vx, vz in checks:
h = sample(vx, vz)
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
% (label, vx, vz, h))
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
if abs(sample(vx, vz)) > 1e-4:
print("ERROR: playable area not flat at", vx, vz)
ok = False
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
if sample(vx, vz) > -4.9:
print("ERROR: lake area not deep enough at", vx, vz)
ok = False
if not ok:
sys.exit(1)
print("heightmap written to %s (%dx%d), all checks passed"
% (out_path, RES, RES))
if __name__ == "__main__":
main()
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-sokoban",
"startScene": "demo_scene_interior.json",
"gameMode": true
}
@@ -0,0 +1,66 @@
# Stage everything demoSokoban needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-sokoban.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# Small demo-owned files are COPIED, except the demo scenes, which are
# SYMLINKED to the source tree so scene edits are visible to the demo
# without a rebuild (the demo never saves scenes, so nothing writes through
# the links); the big pre-staged runtime directories
# are SYMLINKED from the editScene binary directory (Ogre FileSystem
# locations follow symlinks, and copying would duplicate hundreds of MB on
# every build). The symlink targets are populated by the editSceneEditor
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
foreach(f demo_scene_interior.json demo_scene_exterior.json
demo_scene_vehicletest.json)
set(link "${DEMO_DIR}/${f}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE "${link}")
endif()
file(CREATE_LINK "${SRC_DIR}/${f}" "${link}" SYMBOLIC)
endforeach()
# Terrain heightmap: staged at configure time by a configure_file COPYONLY
# in CMakeLists.txt (to heightmaps/4242424300000001/), which re-copies it
# automatically when the source file changes. Not symlinked: the fixup
# layer and save path write next to it.
# Character prefab for the spawner's registry entry (registryId 2). It is
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
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
# 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)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
+211
View File
@@ -35,6 +35,10 @@
#include <Jolt/Physics/Collision/CollisionCollectorImpl.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <Jolt/Physics/Body/BodyActivationListener.h>
#include <Jolt/Physics/Body/BodyLock.h>
#include <Jolt/Physics/Vehicle/VehicleConstraint.h>
#include <Jolt/Physics/Vehicle/WheeledVehicleController.h>
#include <Jolt/Physics/Vehicle/VehicleCollisionTester.h>
#include <Jolt/Renderer/DebugRendererSimple.h>
// STL includes
@@ -568,6 +572,12 @@ class Physics {
JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f);
std::unordered_map<uint32_t, JPH::Ref<JPH::GroupFilterTable> > groupFilters;
/* F10 vehicles: live constraints (for teardown) and one shared
* ray-cast collision tester (stateless; MOVING object layer hits
* both broadphase layers with our filter tables). */
std::set<JPH::VehicleConstraint *> vehicles;
JPH::Ref<JPH::VehicleCollisionTester> vehicleTester;
public:
class ActivationListener : public JPH::BodyActivationListener {
public:
@@ -602,6 +612,164 @@ public:
return nullptr;
}
/* --- F10 vehicles (mirrors the Jolt VehicleConstraintTest
* sample) --- */
JPH::VehicleConstraint *
createVehicle(const JPH::BodyID &chassisBodyId,
const JoltPhysicsWrapper::VehicleDesc &desc)
{
JPH::BodyLockWrite lock(physics_system.GetBodyLockInterface(),
chassisBodyId);
if (!lock.Succeeded())
return nullptr;
JPH::Body &body = lock.GetBody();
JPH::VehicleConstraintSettings settings;
if (desc.maxPitchRollAngleDeg > 0.0f)
settings.mMaxPitchRollAngle =
JPH::DegreesToRadians(desc.maxPitchRollAngleDeg);
settings.mWheels.reserve(desc.wheels.size());
for (const JoltPhysicsWrapper::VehicleWheelDesc &wd :
desc.wheels) {
JPH::WheelSettingsWV *w = new JPH::WheelSettingsWV;
/* Chassis-local attachment point (Vec3, not RVec3). */
w->mPosition = JPH::Vec3(wd.position.x, wd.position.y,
wd.position.z);
w->mSuspensionDirection = JPH::Vec3(0, -1, 0);
w->mSteeringAxis = JPH::Vec3(0, 1, 0);
w->mWheelUp = JPH::Vec3(0, 1, 0);
w->mWheelForward = JPH::Vec3(0, 0, 1);
w->mSuspensionMinLength = wd.suspensionMinLength;
w->mSuspensionMaxLength = wd.suspensionMaxLength;
w->mSuspensionSpring.mFrequency =
wd.suspensionFrequency;
w->mSuspensionSpring.mDamping = wd.suspensionDamping;
w->mMaxSteerAngle =
JPH::DegreesToRadians(wd.maxSteerAngleDeg);
w->mMaxHandBrakeTorque = wd.maxHandBrakeTorque;
w->mRadius = wd.radius;
w->mWidth = wd.width;
settings.mWheels.push_back(w);
}
JPH::WheeledVehicleControllerSettings *controller =
new JPH::WheeledVehicleControllerSettings;
settings.mController = controller;
controller->mEngine.mMaxTorque = desc.maxTorque;
/* One differential per axle pair (wheels 0/1, 2/3, ...)
* where both wheels are driven. Engine torque ratios must
* sum to 1 across differentials (Jolt assert), so split
* evenly. */
for (size_t i = 0; i + 1 < desc.wheels.size(); i += 2) {
if (!desc.wheels[i].driven ||
!desc.wheels[i + 1].driven)
continue;
JPH::VehicleDifferentialSettings diff;
diff.mLeftWheel = (int)i;
diff.mRightWheel = (int)i + 1;
controller->mDifferentials.push_back(diff);
}
if (!controller->mDifferentials.empty()) {
float ratio =
1.0f / (float)controller->mDifferentials.size();
for (JPH::VehicleDifferentialSettings &d :
controller->mDifferentials)
d.mEngineTorqueRatio = ratio;
}
JPH::VehicleConstraint *constraint =
new JPH::VehicleConstraint(body, settings);
/* Same tyre-impulse compensation as the Jolt sample: the
* sample settings were tuned with N-velocity-steps-times
* more longitudinal impulse than intended, so the max
* impulse is scaled to keep the tuned feel. */
static_cast<JPH::WheeledVehicleController *>(
constraint->GetController())
->SetTireMaxImpulseCallback(
[](JPH::uint, float &outLongitudinalImpulse,
float &outLateralImpulse,
float inSuspensionImpulse,
float inLongitudinalFriction,
float inLateralFriction, float, float,
float) {
outLongitudinalImpulse =
10.0f * inLongitudinalFriction *
inSuspensionImpulse;
outLateralImpulse = inLateralFriction *
inSuspensionImpulse;
});
if (!vehicleTester)
vehicleTester =
new JPH::VehicleCollisionTesterRay(
Layers::MOVING);
constraint->SetVehicleCollisionTester(vehicleTester);
physics_system.AddConstraint(constraint);
physics_system.AddStepListener(constraint);
vehicles.insert(constraint);
return constraint;
}
void destroyVehicle(JPH::VehicleConstraint *vehicle)
{
if (!vehicle)
return;
auto it = vehicles.find(vehicle);
if (it == vehicles.end())
return;
physics_system.RemoveStepListener(vehicle);
physics_system.RemoveConstraint(vehicle);
vehicles.erase(it);
}
void setVehicleInput(JPH::VehicleConstraint *vehicle, float forward,
float right, float brake, float handbrake)
{
if (!vehicle || vehicles.find(vehicle) == vehicles.end())
return;
if (forward != 0.0f || right != 0.0f || brake != 0.0f ||
handbrake != 0.0f)
physics_system.GetBodyInterface().ActivateBody(
vehicle->GetVehicleBody()->GetID());
static_cast<JPH::WheeledVehicleController *>(
vehicle->GetController())
->SetDriverInput(forward, right, brake, handbrake);
}
float getVehicleForwardSpeed(JPH::VehicleConstraint *vehicle) const
{
const JPH::Body *body = vehicle->GetVehicleBody();
return (body->GetRotation().Conjugated() *
body->GetLinearVelocity())
.GetZ();
}
void getWheelWorldTransform(JPH::VehicleConstraint *vehicle,
int wheelIndex, Ogre::Vector3 &position,
Ogre::Quaternion &orientation) const
{
/* The wheel visual (a cylinder mesh) is Y-axis aligned, so
* Y is the rotational axis and X the "up" reference. */
JPH::RMat44 t = vehicle->GetWheelWorldTransform(
(JPH::uint)wheelIndex, JPH::Vec3::sAxisY(),
JPH::Vec3::sAxisX());
position = JoltPhysics::convert(t.GetTranslation());
orientation = JoltPhysics::convert(t.GetQuaternion());
}
void destroyAllVehicles()
{
for (JPH::VehicleConstraint *v : vehicles) {
physics_system.RemoveStepListener(v);
physics_system.RemoveConstraint(v);
}
vehicles.clear();
}
void setBodyDrawFilter(JPH::BodyDrawFilter *filter)
{
mBodyDrawFilter = filter;
@@ -762,6 +930,10 @@ public:
}
~Physics()
{
/* Vehicles hold step-listener registrations and body
* references; remove them before the system goes down. */
destroyAllVehicles();
// Unregisters all types with the factory and cleans up the default material
JPH::UnregisterTypes();
@@ -2126,5 +2298,44 @@ JPH::GroupFilterTable *JoltPhysicsWrapper::getGroupFilter(uint32_t groupId) cons
return phys->getGroupFilter(groupId);
}
JPH::VehicleConstraint *
JoltPhysicsWrapper::createVehicle(const JPH::BodyID &chassisBody,
const VehicleDesc &desc)
{
return phys->createVehicle(chassisBody, desc);
}
void JoltPhysicsWrapper::destroyVehicle(JPH::VehicleConstraint *vehicle)
{
phys->destroyVehicle(vehicle);
}
void JoltPhysicsWrapper::setVehicleInput(JPH::VehicleConstraint *vehicle,
float forward, float right,
float brake, float handbrake)
{
phys->setVehicleInput(vehicle, forward, right, brake, handbrake);
}
float JoltPhysicsWrapper::getVehicleForwardSpeed(
JPH::VehicleConstraint *vehicle) const
{
return phys->getVehicleForwardSpeed(vehicle);
}
void JoltPhysicsWrapper::getWheelWorldTransform(
JPH::VehicleConstraint *vehicle, int wheelIndex,
Ogre::Vector3 &position, Ogre::Quaternion &orientation) const
{
phys->getWheelWorldTransform(vehicle, wheelIndex, position,
orientation);
}
int JoltPhysicsWrapper::getVehicleWheelCount(
JPH::VehicleConstraint *vehicle) const
{
return (int)vehicle->GetWheels().size();
}
template <>
JoltPhysicsWrapper *Ogre::Singleton<JoltPhysicsWrapper>::msSingleton = 0;
+54
View File
@@ -12,6 +12,7 @@
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <Jolt/Physics/EActivation.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
#include <vector>
void physics();
namespace JPH
{
@@ -21,6 +22,7 @@ class ContactManifold;
class ContactSettings;
class SubShapeIDPair;
class PhysicsSystem;
class VehicleConstraint;
}
// Layer that objects can be in, determines which other objects it can collide with
// Typically you at least want to have 1 layer for moving bodies and 1 layer for static bodies, but you can have more
@@ -271,5 +273,57 @@ public:
uint32_t groupId,
uint32_t numSubGroups = Layers::MAX_CHARACTER_SUBGROUPS);
JPH::GroupFilterTable *getGroupFilter(uint32_t groupId) const;
/* --- F10 vehicle support (Jolt VehicleConstraint) ---------------
* See demos/demo-sokoban/PLAN.md. Modelled on the Jolt samples
* (Samples/Tests/Vehicle/VehicleConstraintTest.cpp): the chassis is
* a regular dynamic body created through the usual RigidBody path;
* createVehicle() attaches a VehicleConstraint with a
* WheeledVehicleController to it and registers the constraint as a
* physics step listener so suspension raycasts run inside the
* physics step. */
struct VehicleWheelDesc {
/* Suspension attachment point in chassis space. */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
float radius = 0.3f;
float width = 0.15f;
float suspensionMinLength = 0.05f;
float suspensionMaxLength = 0.3f;
float suspensionFrequency = 2.0f;
float suspensionDamping = 0.8f;
/* 0 = not steered. */
float maxSteerAngleDeg = 0.0f;
/* Engine torque is routed through axle differentials pairing
* wheels (0,1), (2,3), ...; only pairs with both wheels
* driven get a differential. */
bool driven = false;
/* 0 = no handbrake on this wheel. */
float maxHandBrakeTorque = 0.0f;
};
struct VehicleDesc {
std::vector<VehicleWheelDesc> wheels;
float maxTorque = 400.0f;
/* Degrees; 0 = unlimited. */
float maxPitchRollAngleDeg = 60.0f;
};
/* Create/destroy a vehicle on an existing (already added) dynamic
* chassis body. Returns nullptr on failure. */
JPH::VehicleConstraint *createVehicle(const JPH::BodyID &chassisBody,
const VehicleDesc &desc);
void destroyVehicle(JPH::VehicleConstraint *vehicle);
/* Driver input: forward -1..1, right -1..1, brake 0..1,
* handbrake 0..1. Activates the chassis body on non-zero input. */
void setVehicleInput(JPH::VehicleConstraint *vehicle, float forward,
float right, float brake, float handbrake);
/* Chassis forward speed in m/s (local +Z), for brake/reverse
* logic in the caller. */
float getVehicleForwardSpeed(JPH::VehicleConstraint *vehicle) const;
/* Wheel world transform for the visual wheel node; the wheel mesh
* is assumed Y-axis aligned (OGRE cylinder convention). */
void getWheelWorldTransform(JPH::VehicleConstraint *vehicle,
int wheelIndex, Ogre::Vector3 &position,
Ogre::Quaternion &orientation) const;
int getVehicleWheelCount(JPH::VehicleConstraint *vehicle) const;
};
#endif
@@ -5065,6 +5065,11 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity,
!params.sceneSwitchPath.empty())
params.doorId = grid.ensureGridUid() + ":" + key;
params.edgeKey = key;
/* F1: the occluder tunnel goes on the VOID side of the
* doorway: door-local +Z points outward from the owning
* cell, where normal grids hide the missing room; for
* exteriorOnly grids the missing interior is inward (-Z). */
params.occluderSide = grid.exteriorOnlyMode() ? -1.0f : 1.0f;
params.friction = grid.friction;
params.leafMeshName = leafMeshName;
params.customMesh = usingCustomMesh;
+242 -39
View File
@@ -14,8 +14,182 @@
#include <OgreMaterialManager.h>
#include <OgreTechnique.h>
#include <OgrePass.h>
#include <OgreManualObject.h>
#include <OgreLogManager.h>
#include <ProceduralBoxGenerator.h>
#include <cmath>
#include <cstdio>
/* F1: the black occluder material. NOTE: the material must keep
* lighting ENABLED with all-black colours - with lighting disabled RTSS
* ignores the material colours (FFPColour defaults the pass output to
* white), which made the occluder render white. */
static void ensureOccluderMaterial(const Ogre::String &group)
{
if (Ogre::MaterialManager::getSingleton()
.getByName("CellGridDoorOccluderBlack", group)
.isNull()) {
Ogre::MaterialPtr mat =
Ogre::MaterialManager::getSingleton().create(
"CellGridDoorOccluderBlack", group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(Ogre::ColourValue::Black);
pass->setAmbient(Ogre::ColourValue::Black);
pass->setSpecular(Ogre::ColourValue::Black);
pass->setEmissive(Ogre::ColourValue::Black);
pass->setCullingMode(Ogre::CULL_NONE);
}
}
/* F1: open-front black tunnel for scene-switch doors, covering the
* doorway on the void side (door-local Z times `side`): the back panel
* sits behind the fully-open leaf sweep so the swinging leaf stays
* visible whichever way it swings, and side/top/bottom walls close the
* gaps around the frame so no sliver of the ungenerated void shows.
* The coordinates are baked into the mesh in hinge-local space (+X
* along the doorway from the hinge, the tunnel extending to `side` *
* Z), so the occluder node gets no position offset and no scale.
* Meshes are shared between doors of equal size and side. */
static Ogre::String
occluderTunnelMesh(const Ogre::Vector3 &center,
const Ogre::Vector3 &halfExtents, float side,
const Ogre::String &group)
{
const float hx = halfExtents.x;
const float hy = halfExtents.y;
const float hz = halfExtents.z;
/* The leaf spans [0, x1] from the hinge; its tip sweeps at most
* x1 behind the closed plane (openAngle >= 90). Keep the back
* panel one leaf thickness plus a small margin behind that. */
const float x0 = center.x - hx, x1 = center.x + hx;
const float y0 = center.y - hy, y1 = center.y + hy;
const float z0 = center.z + side * (hz + 0.02f);
const float z1 = z0 + side * (x1 + hz * 2.0f + 0.05f);
/* Flare the walls INTO the frame/jamb past the leaf/frame
* clearances (0.02 m per side, 0.05 m at the top): with a smaller
* flare, sight lines through the open doorway slip over the top
* wall / past the side walls into the void between the jamb inner
* face and the wall, showing sky/terrain around the black
* corridor. 6 cm puts the wall edges inside the jamb boxes (0.1 m
* thick, inner faces at the clearances), so any ray past a wall
* hits the frame instead, with no z-fighting against the jamb
* faces. */
const float f = 0.06f;
char name[160];
snprintf(name, sizeof(name),
"CellGridDoorOccluderTunnel_%d_%d_%d_%d_%d_%d_%d",
(int)lroundf(center.x * 1000.0f),
(int)lroundf(center.y * 1000.0f),
(int)lroundf(center.z * 1000.0f),
(int)lroundf(hx * 1000.0f), (int)lroundf(hy * 1000.0f),
(int)lroundf(hz * 1000.0f), (int)side);
if (!Ogre::MeshManager::getSingleton().getByName(name, group).isNull())
return name;
const float x0f = x0 - f, x1f = x1 + f;
const float y0f = y0 - f, y1f = y1 + f;
Ogre::ManualObject mo(name);
mo.begin("CellGridDoorOccluderBlack",
Ogre::RenderOperation::OT_TRIANGLE_LIST);
auto quad = [&mo](const Ogre::Vector3 &a, const Ogre::Vector3 &b,
const Ogre::Vector3 &c, const Ogre::Vector3 &d,
const Ogre::Vector3 &normal) {
Ogre::uint32 base = mo.getCurrentVertexCount();
mo.position(a);
mo.normal(normal);
mo.position(b);
mo.normal(normal);
mo.position(c);
mo.normal(normal);
mo.position(d);
mo.normal(normal);
mo.triangle(base, base + 1, base + 2);
mo.triangle(base, base + 2, base + 3);
};
/* Back panel (facing the player, against the tunnel depth). */
quad(Ogre::Vector3(x0f, y0f, z1), Ogre::Vector3(x1f, y0f, z1),
Ogre::Vector3(x1f, y1f, z1), Ogre::Vector3(x0f, y1f, z1),
Ogre::Vector3(0, 0, -side));
/* Left / right walls (facing into the tunnel). */
quad(Ogre::Vector3(x0f, y0f, z0), Ogre::Vector3(x0f, y1f, z0),
Ogre::Vector3(x0f, y1f, z1), Ogre::Vector3(x0f, y0f, z1),
Ogre::Vector3::UNIT_X);
quad(Ogre::Vector3(x1f, y0f, z0), Ogre::Vector3(x1f, y0f, z1),
Ogre::Vector3(x1f, y1f, z1), Ogre::Vector3(x1f, y1f, z0),
Ogre::Vector3::NEGATIVE_UNIT_X);
/* Bottom / top walls. */
quad(Ogre::Vector3(x0f, y0f, z0), Ogre::Vector3(x1f, y0f, z0),
Ogre::Vector3(x1f, y0f, z1), Ogre::Vector3(x0f, y0f, z1),
Ogre::Vector3::UNIT_Y);
quad(Ogre::Vector3(x0f, y1f, z0), Ogre::Vector3(x0f, y1f, z1),
Ogre::Vector3(x1f, y1f, z1), Ogre::Vector3(x1f, y1f, z0),
Ogre::Vector3::NEGATIVE_UNIT_Y);
mo.end();
mo.convertToMesh(name, group);
return name;
}
/* F1: flat black gap shield for scene-switch doors, shown while the
* door is fully closed (the tunnel covers the swinging/open states):
* the leaf is sized with clearances against the frame opening (0.02 m
* per side, 0.05 m at the top), and without a backing those slits show
* the ungenerated void (sky/terrain) around the closed door. The
* shield is a single quad 1 cm behind the closed leaf's back face on
* the void side (`side` * Z), spanning the leaf box plus a margin that
* exceeds the clearances; the margin edges end up inside the jamb
* boxes and the wall thickness, so the quad never pokes out of the
* frame. Like the tunnel, the coordinates are baked into the mesh in
* hinge-local space and meshes are shared between doors of equal size
* and side. */
static Ogre::String
occluderShieldMesh(const Ogre::Vector3 &center,
const Ogre::Vector3 &halfExtents, float side,
const Ogre::String &group)
{
const float hx = halfExtents.x;
const float hy = halfExtents.y;
const float hz = halfExtents.z;
const float x0 = center.x - hx, x1 = center.x + hx;
const float y0 = center.y - hy, y1 = center.y + hy;
const float z = center.z + side * (hz + 0.01f);
/* Margin beyond the leaf box: must exceed the leaf/frame
* clearances (0.02 m sides, 0.05 m top). */
const float m = 0.06f;
char name[160];
snprintf(name, sizeof(name),
"CellGridDoorOccluderShield_%d_%d_%d_%d_%d_%d_%d",
(int)lroundf(center.x * 1000.0f),
(int)lroundf(center.y * 1000.0f),
(int)lroundf(center.z * 1000.0f),
(int)lroundf(hx * 1000.0f), (int)lroundf(hy * 1000.0f),
(int)lroundf(hz * 1000.0f), (int)side);
if (!Ogre::MeshManager::getSingleton().getByName(name, group).isNull())
return name;
const float x0m = x0 - m, x1m = x1 + m;
const float y0m = y0 - m, y1m = y1 + m;
Ogre::ManualObject mo(name);
mo.begin("CellGridDoorOccluderBlack",
Ogre::RenderOperation::OT_TRIANGLE_LIST);
Ogre::Vector3 normal(0, 0, -side);
Ogre::uint32 base = mo.getCurrentVertexCount();
mo.position(x0m, y0m, z);
mo.normal(normal);
mo.position(x1m, y0m, z);
mo.normal(normal);
mo.position(x1m, y1m, z);
mo.normal(normal);
mo.position(x0m, y1m, z);
mo.normal(normal);
mo.triangle(base, base + 1, base + 2);
mo.triangle(base, base + 2, base + 3);
mo.end();
mo.convertToMesh(name, group);
return name;
}
flecs::entity DoorBuilder::build(flecs::world &world,
Ogre::SceneManager *sceneMgr,
@@ -121,11 +295,15 @@ flecs::entity DoorBuilder::build(flecs::world &world,
/* F6: declare the store defaults and snap the door to the
* persisted open state (rebuilds and reloads restore the
* state the door had when it was saved/left). */
* state the door had when it was saved/left). Scene-switch
* doors are the exception: they always reopen closed (the
* switch fires from the fully-open pose, so a persisted
* "open" would restore the door ajar on return). */
bool wasOpen = false;
if (!door.doorId.empty()) {
wasOpen = DoorSystem::declareDoorDefaults(
door.doorId, params.lockedByDefault);
door.doorId, params.lockedByDefault,
!door.sceneSwitchPath.empty());
}
if (wasOpen) {
door.isOpen = true;
@@ -164,37 +342,36 @@ flecs::entity DoorBuilder::build(flecs::world &world,
collider.offset = colliderCenter;
colliderEntity.set<PhysicsColliderComponent>(collider);
/* F1: scene-switch doors get an unlit black occluder box
* covering the doorway, a few cm behind the closed leaf
* plane, so the player never sees the missing room
* interior through the opened doorway before the switch
* fires. The node hangs off the PARENT node (not the
* hinge), so it does not swing with the leaf; DoorSystem
* toggles its visibility from the swing angle. */
/* F1: scene-switch doors get a black occluder covering the
* doorway, so the player never sees the missing room interior
* through the opened doorway before the switch fires. All of
* them get the same open-front tunnel on the VOID side of the
* doorway (params.occluderSide: door-local +Z outward from the
* owning cell, -Z for exteriorOnly grids whose missing interior
* is inward); its depth covers the fully-open leaf sweep, so the
* leaf stays visible whether it swings towards or away from the
* player, and the side/top/bottom walls close the gaps around
* the frame. The geometry is baked into the mesh in hinge-local
* space and the node hangs off the PARENT node (not the hinge),
* so it does not swing with the leaf; DoorSystem toggles its
* visibility from the swing angle. A second flat gap-shield quad
* sits just behind the closed leaf and is visible only while the
* door is fully closed, blacking out the leaf/frame clearance
* slits the tunnel (hidden while closed) does not cover. */
if (!door.sceneSwitchPath.empty()) {
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (Ogre::MeshManager::getSingleton()
.getByName("CellGridDoorOccluderBox", group)
.isNull()) {
Procedural::BoxGenerator boxGen;
boxGen.setSizeX(1.0f).setSizeY(1.0f).setSizeZ(1.0f);
boxGen.realizeMesh("CellGridDoorOccluderBox", group);
}
if (Ogre::MaterialManager::getSingleton()
.getByName("CellGridDoorOccluderBlack", group)
.isNull()) {
Ogre::MaterialPtr mat =
Ogre::MaterialManager::getSingleton().create(
"CellGridDoorOccluderBlack", group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setLightingEnabled(false);
pass->setDiffuse(Ogre::ColourValue::Black);
}
ensureOccluderMaterial(group);
Ogre::String meshName = occluderTunnelMesh(
colliderCenter, colliderHalfExtents,
params.occluderSide, group);
Ogre::Vector3 occScale = Ogre::Vector3::UNIT_SCALE;
Ogre::Vector3 occPos = hingePos;
Ogre::Entity *occEnt = nullptr;
try {
occEnt = sceneMgr->createEntity("CellGridDoorOccluderBox");
occEnt = sceneMgr->createEntity(meshName);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"DoorBuilder: Error creating door occluder: " +
@@ -204,16 +381,6 @@ flecs::entity DoorBuilder::build(flecs::world &world,
occEnt->setMaterialName("CellGridDoorOccluderBlack");
occEnt->setVisible(wasOpen);
Ogre::Vector3 occScale(colliderHalfExtents.x * 2.0f,
colliderHalfExtents.y * 2.0f,
0.05f);
Ogre::Vector3 occPos =
hingePos +
doorwayRot *
(colliderCenter +
Ogre::Vector3(0, 0,
colliderHalfExtents.z +
0.02f));
Ogre::SceneNode *occNode =
parentNode->createChildSceneNode();
occNode->setPosition(occPos);
@@ -227,11 +394,47 @@ flecs::entity DoorBuilder::build(flecs::world &world,
{occNode, occPos, doorwayRot, occScale});
RenderableComponent occRenderable;
occRenderable.entity = occEnt;
occRenderable.meshName = "CellGridDoorOccluderBox";
occRenderable.meshName = meshName;
occEntity.set<RenderableComponent>(occRenderable);
doorEntity.get_mut<DoorComponent>().occluder = occEnt;
}
Ogre::String shieldMeshName = occluderShieldMesh(
colliderCenter, colliderHalfExtents,
params.occluderSide, group);
Ogre::Entity *shieldEnt = nullptr;
try {
shieldEnt = sceneMgr->createEntity(shieldMeshName);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"DoorBuilder: Error creating door gap shield: " +
std::string(e.what()));
}
if (shieldEnt) {
shieldEnt->setMaterialName("CellGridDoorOccluderBlack");
shieldEnt->setVisible(!wasOpen);
Ogre::SceneNode *shieldNode =
parentNode->createChildSceneNode();
shieldNode->setPosition(occPos);
shieldNode->setOrientation(doorwayRot);
shieldNode->setScale(occScale);
shieldNode->attachObject(shieldEnt);
flecs::entity shieldEntity = world.entity();
shieldEntity.child_of(doorEntity);
shieldEntity.set<TransformComponent>(
{shieldNode, occPos, doorwayRot, occScale});
RenderableComponent shieldRenderable;
shieldRenderable.entity = shieldEnt;
shieldRenderable.meshName = shieldMeshName;
shieldEntity.set<RenderableComponent>(
shieldRenderable);
doorEntity.get_mut<DoorComponent>().gapShield =
shieldEnt;
}
}
return doorEntity;
+19 -4
View File
@@ -18,13 +18,21 @@
* leaf, DoorComponent, RigidBodyComponent static, ActuatorComponent)
* collider child (PhysicsColliderComponent box of the closed
* leaf; disabled by DoorSystem while not fully closed)
* occluder child (F1, scene-switch doors only: unlit black box
* behind the leaf plane, node on the PARENT node so it does not
* swing; DoorSystem toggles its visibility)
* occluder child (F1, scene-switch doors only: open-front
* black tunnel on the void side of the doorway - door-local
* +Z normally, -Z for exteriorOnly grids -, node on the
* PARENT node so it does not swing; DoorSystem toggles its
* visibility, hidden while the door is fully closed)
* gap-shield child (F1, scene-switch doors only: flat black
* quad just behind the closed leaf, visible only while the
* door is fully closed; blacks out the leaf/frame clearance
* slits the hidden tunnel does not cover)
*
* The builder also declares the F6 store defaults for persistent doors
* (DoorSystem::declareDoorDefaults) and snaps a door that was left open
* straight to its persisted pose with the collider disabled.
* straight to its persisted pose with the collider disabled; scene-switch
* doors always reopen closed instead (declareDoorDefaults resets their
* stored isOpen).
*
* Leaf mesh creation stays with the caller: pass a ready mesh name
* (CellGrid generates its UV-mapped procedural leaf; standalone doors
@@ -37,6 +45,13 @@ struct DoorBuildParams {
float openAngle = 100.0f; // degrees
float openSpeed = 180.0f; // degrees per second
bool swingReversed = false; // F3
/* F1: side of the doorway the scene-switch occluder tunnel
* extends to, in door-local Z: +1 = +Z (outward from the owning
* cell - the default; the missing room/void is there), -1 = -Z
* (exteriorOnly grids, whose missing interior is inward). The
* tunnel depth covers the full leaf sweep, so either swing
* direction stays visible. */
float occluderSide = 1.0f;
std::string actionName; // optional actuator action
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
+15 -4
View File
@@ -67,7 +67,7 @@ void DoorSystem::setDoorLocked(const std::string &doorId, bool locked)
}
bool DoorSystem::declareDoorDefaults(const std::string &doorId,
bool lockedByDefault)
bool lockedByDefault, bool sceneSwitchDoor)
{
if (doorId.empty())
return false;
@@ -75,7 +75,15 @@ bool DoorSystem::declareDoorDefaults(const std::string &doorId,
GlobalStateStore &store = GlobalStateStore::getInstance();
store.declareDefault("door." + doorId + ".locked", lockedByDefault);
store.declareDefault("door." + doorId + ".isOpen", false);
return store.getBool("door." + doorId + ".isOpen");
bool isOpen = store.getBool("door." + doorId + ".isOpen");
if (isOpen && sceneSwitchDoor) {
/* Scene-switch doors always reopen closed (the switch
* fires from the fully-open pose); reset the stored state
* so Lua/queries never see a stale "open". */
store.set("door." + doorId + ".isOpen", false);
isOpen = false;
}
return isOpen;
}
Ogre::Quaternion DoorSystem::swingOrientation(const DoorComponent &door,
@@ -167,10 +175,13 @@ void DoorSystem::update(float deltaTime)
}
}
// F1: the black occluder is visible unless the door is
// fully closed
// F1: the black occluder tunnel is visible unless the door
// is fully closed; the gap shield backs the leaf/frame
// clearance slits exactly while it is
if (door.occluder)
door.occluder->setVisible(door.currentAngle != 0.0f);
if (door.gapShield)
door.gapShield->setVisible(door.currentAngle == 0.0f);
});
// Refresh the per-door "door_unlock_<doorId>" subscriptions
+11 -5
View File
@@ -20,8 +20,9 @@
* Persistent door state (F6) lives in the GlobalStateStore under
* "door.<doorId>.locked" / "door.<doorId>.isOpen" (doorId is the F0 global
* door ID "<gridUid>:<edgeKey>"; empty = ephemeral door). The system
* writes isOpen when a swing completes; CellGridSystem snaps a rebuilt
* door to the persisted state. Locked state is managed through the
* writes isOpen when a swing completes; the door builders snap a rebuilt
* door to the persisted state (except scene-switch doors, which always
* reopen closed). Locked state is managed through the
* static helpers below and through the EventBus: sending
* "door_unlock_<doorId>" or the generic "door_unlock" event (param
* door_id) unlocks a door; "door_unlocked_<doorId>" / "door_unlocked" are
@@ -45,10 +46,15 @@ public:
static void setDoorLocked(const std::string &doorId, bool locked);
/* Declare the store defaults for a persistent door (called by the
* door builders when a door is (re)created) and return the
* persisted open state. */
* door builders when a door is (re)created) and return the open
* state the door should start in. Scene-switch doors
* (sceneSwitchDoor = true) always reopen closed and their stored
* isOpen is reset: the switch fires from the fully-open pose, so a
* persisted "open" would otherwise restore the door ajar on
* return. */
static bool declareDoorDefaults(const std::string &doorId,
bool lockedByDefault);
bool lockedByDefault,
bool sceneSwitchDoor = false);
/* Hinge orientation for a swing angle (F3: swingReversed negates
* the applied angle; the stored angles stay positive). Used by
@@ -35,6 +35,14 @@ void EditorPhysicsSystem::update(float deltaTime)
if (!m_initialized || !m_physics)
return;
/* Clamp the step delta: the first frame after a scene load carries
* the whole load time (seconds), and without a clamp the wrapper
* would run hundreds of catch-up steps in one frame - dynamic bodies
* free-fall through not-yet-streamed terrain colliders or get
* launched sky-high by buoyancy impulses at depth. */
if (deltaTime > 0.1f)
deltaTime = 0.1f;
// Sync bodies before simulation
syncBodies();
@@ -230,12 +238,18 @@ EditorPhysicsSystem::buildCompoundShape(flecs::entity rigidBodyEntity)
rigidBodyEntity.has<TransformComponent>()) {
auto &collider =
rigidBodyEntity.get_mut<PhysicsColliderComponent>();
auto &transform = rigidBodyEntity.get<TransformComponent>();
JPH::ShapeRefC shape = createShape(collider);
if (shape) {
/* The body is created at this entity's world
* transform, so its own collider sits at the
* origin of the compound (intra-entity offset is
* handled by collider.offset in createShape).
* Using transform.position here would double-apply
* the entity position and strand the collider at
* 2x the world position. */
shapes.push_back(shape);
positions.push_back(transform.position);
rotations.push_back(transform.rotation);
positions.push_back(Ogre::Vector3::ZERO);
rotations.push_back(Ogre::Quaternion::IDENTITY);
}
}
@@ -8,6 +8,7 @@
#include "../components/EntityName.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/RigidBody.hpp"
#include "../components/Vehicle.hpp"
#include "../components/PhysicsCollider.hpp"
#include "../components/Light.hpp"
#include "../components/Camera.hpp"
@@ -265,6 +266,10 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["rigidBody"] = serializeRigidBody(entity);
}
if (entity.has<VehicleComponent>()) {
json["vehicle"] = serializeVehicle(entity);
}
if (entity.has<PhysicsColliderComponent>()) {
json["collider"] = serializeCollider(entity);
}
@@ -499,6 +504,10 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeRigidBody(entity, json["rigidBody"]);
}
if (json.contains("vehicle")) {
deserializeVehicle(entity, json["vehicle"]);
}
if (json.contains("collider")) {
deserializeCollider(entity, json["collider"]);
}
@@ -753,6 +762,10 @@ void SceneSerializer::deserializeEntityComponents(
deserializeRigidBody(entity, json["rigidBody"]);
}
if (json.contains("vehicle")) {
deserializeVehicle(entity, json["vehicle"]);
}
if (json.contains("collider")) {
deserializeCollider(entity, json["collider"]);
}
@@ -1629,6 +1642,86 @@ void SceneSerializer::deserializeRigidBody(flecs::entity entity,
entity.set<RigidBodyComponent>(rb);
}
nlohmann::json SceneSerializer::serializeVehicle(flecs::entity entity)
{
auto &vehicle = entity.get<VehicleComponent>();
nlohmann::json json;
json["maxTorque"] = vehicle.maxTorque;
json["maxPitchRollAngleDeg"] = vehicle.maxPitchRollAngleDeg;
json["seatOffset"] = { { "x", vehicle.seatOffset.x },
{ "y", vehicle.seatOffset.y },
{ "z", vehicle.seatOffset.z } };
json["wheelMeshName"] = vehicle.wheelMeshName;
nlohmann::json wheels = nlohmann::json::array();
for (const VehicleWheel &w : vehicle.wheels) {
nlohmann::json wj;
wj["position"] = { { "x", w.position.x },
{ "y", w.position.y },
{ "z", w.position.z } };
wj["radius"] = w.radius;
wj["width"] = w.width;
wj["suspensionMinLength"] = w.suspensionMinLength;
wj["suspensionMaxLength"] = w.suspensionMaxLength;
wj["suspensionFrequency"] = w.suspensionFrequency;
wj["suspensionDamping"] = w.suspensionDamping;
wj["maxSteerAngleDeg"] = w.maxSteerAngleDeg;
wj["driven"] = w.driven;
wj["maxHandBrakeTorque"] = w.maxHandBrakeTorque;
wheels.push_back(wj);
}
json["wheels"] = wheels;
return json;
}
void SceneSerializer::deserializeVehicle(flecs::entity entity,
const nlohmann::json &json)
{
VehicleComponent vehicle;
vehicle.maxTorque = json.value("maxTorque", 400.0f);
vehicle.maxPitchRollAngleDeg =
json.value("maxPitchRollAngleDeg", 60.0f);
if (json.contains("seatOffset")) {
auto &so = json["seatOffset"];
vehicle.seatOffset = Ogre::Vector3(so.value("x", 0.0f),
so.value("y", 1.0f),
so.value("z", 0.0f));
}
vehicle.wheelMeshName = json.value("wheelMeshName", "");
if (json.contains("wheels")) {
for (const auto &wj : json["wheels"]) {
VehicleWheel w;
if (wj.contains("position")) {
auto &p = wj["position"];
w.position = Ogre::Vector3(p.value("x", 0.0f),
p.value("y", 0.0f),
p.value("z", 0.0f));
}
w.radius = wj.value("radius", 0.3f);
w.width = wj.value("width", 0.15f);
w.suspensionMinLength =
wj.value("suspensionMinLength", 0.1f);
w.suspensionMaxLength =
wj.value("suspensionMaxLength", 0.4f);
w.suspensionFrequency =
wj.value("suspensionFrequency", 1.5f);
w.suspensionDamping =
wj.value("suspensionDamping", 0.7f);
w.maxSteerAngleDeg = wj.value("maxSteerAngleDeg", 0.0f);
w.driven = wj.value("driven", false);
w.maxHandBrakeTorque =
wj.value("maxHandBrakeTorque", 0.0f);
vehicle.wheels.push_back(w);
}
}
entity.set<VehicleComponent>(vehicle);
}
void SceneSerializer::deserializeCollider(flecs::entity entity,
const nlohmann::json &json)
{
@@ -142,6 +142,7 @@ private:
nlohmann::json serializeAnimationTreeTemplate(flecs::entity entity);
nlohmann::json serializeStartupMenu(flecs::entity entity);
nlohmann::json serializePlayerController(flecs::entity entity);
nlohmann::json serializeVehicle(flecs::entity entity);
// CellGrid/Town component serialization
nlohmann::json serializeCellGrid(flecs::entity entity);
@@ -203,6 +204,8 @@ private:
const nlohmann::json &json);
void deserializePlayerController(flecs::entity entity,
const nlohmann::json &json);
void deserializeVehicle(flecs::entity entity,
const nlohmann::json &json);
// CellGrid/Town component deserialization
void deserializeCellGrid(flecs::entity entity,
@@ -0,0 +1,223 @@
#include "VehicleSystem.hpp"
#include "../components/RigidBody.hpp"
#include "../components/Transform.hpp"
#include <Jolt/Physics/Vehicle/VehicleConstraint.h>
#include <OgreLogManager.h>
#include <OgreSceneManager.h>
#include <OgreEntity.h>
VehicleSystem::VehicleSystem(flecs::world &world, JoltPhysicsWrapper *physics)
: m_world(world)
, m_physics(physics)
, m_query(world
.query<VehicleComponent, RigidBodyComponent,
TransformComponent>())
{
m_removeObserver =
m_world.observer<VehicleComponent>("VehicleCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, VehicleComponent &v) {
destroyConstraint(v);
destroyWheelVisuals(e.id());
});
}
VehicleSystem::~VehicleSystem()
{
if (m_removeObserver.is_alive())
m_removeObserver.destruct();
/* Constraints referencing scene bodies must not outlive the
* system; entities are expected to be cleared first (clearScene),
* but remove whatever is left. */
m_query.each(
[this](flecs::entity e, VehicleComponent &v,
RigidBodyComponent &, TransformComponent &) {
destroyConstraint(v);
destroyWheelVisuals(e.id());
});
}
void VehicleSystem::createConstraint(flecs::entity entity,
VehicleComponent &vehicle,
RigidBodyComponent &rigidBody)
{
if (rigidBody.bodyType != RigidBodyComponent::BodyType::Dynamic) {
Ogre::LogManager::getSingleton().logMessage(
"VehicleSystem: entity " + Ogre::StringConverter::toString(
(unsigned long long)entity.id()) +
" has a VehicleComponent but its RigidBody is not "
"dynamic - constraint not created");
return;
}
if (vehicle.wheels.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"VehicleSystem: VehicleComponent has no wheels - "
"constraint not created");
return;
}
JoltPhysicsWrapper::VehicleDesc desc;
desc.maxTorque = vehicle.maxTorque;
desc.maxPitchRollAngleDeg = vehicle.maxPitchRollAngleDeg;
desc.wheels.reserve(vehicle.wheels.size());
for (const VehicleWheel &vw : vehicle.wheels) {
JoltPhysicsWrapper::VehicleWheelDesc wd;
wd.position = vw.position;
wd.radius = vw.radius;
wd.width = vw.width;
wd.suspensionMinLength = vw.suspensionMinLength;
wd.suspensionMaxLength = vw.suspensionMaxLength;
wd.suspensionFrequency = vw.suspensionFrequency;
wd.suspensionDamping = vw.suspensionDamping;
wd.maxSteerAngleDeg = vw.maxSteerAngleDeg;
wd.driven = vw.driven;
wd.maxHandBrakeTorque = vw.maxHandBrakeTorque;
desc.wheels.push_back(wd);
}
vehicle.constraint =
m_physics->createVehicle(rigidBody.bodyID, desc);
vehicle.constraintCreated = vehicle.constraint != nullptr;
if (vehicle.constraintCreated) {
Ogre::LogManager::getSingleton().logMessage(
"VehicleSystem: vehicle constraint created (entity " +
Ogre::StringConverter::toString(
(unsigned long long)entity.id()) +
", " +
Ogre::StringConverter::toString(
(int)vehicle.wheels.size()) +
" wheels)");
createWheelVisuals(entity, vehicle);
}
}
void VehicleSystem::destroyConstraint(VehicleComponent &vehicle)
{
if (!vehicle.constraintCreated)
return;
m_physics->destroyVehicle(vehicle.constraint);
vehicle.constraint = nullptr;
vehicle.constraintCreated = false;
}
void VehicleSystem::createWheelVisuals(flecs::entity entity,
VehicleComponent &vehicle)
{
if (vehicle.wheelMeshName.empty())
return;
if (!entity.has<TransformComponent>())
return;
const TransformComponent &t = entity.get<TransformComponent>();
if (!t.node)
return;
destroyWheelVisuals(entity.id());
WheelVisual visual;
try {
for (size_t i = 0; i < vehicle.wheels.size(); i++) {
Ogre::SceneNode *node = t.node->createChildSceneNode();
Ogre::Entity *ent = t.node->getCreator()->createEntity(
vehicle.wheelMeshName);
node->attachObject(ent);
visual.nodes.push_back(node);
visual.entities.push_back(ent);
}
} catch (const Ogre::Exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"VehicleSystem: cannot create wheel visuals '" +
vehicle.wheelMeshName + "': " + e.getDescription());
destroyWheelVisuals(entity.id());
return;
}
m_wheelVisuals[entity.id()] = visual;
}
void VehicleSystem::destroyWheelVisuals(flecs::entity_t id)
{
/* The wheel nodes are children of the chassis node and die with
* it; just drop the bookkeeping. */
m_wheelVisuals.erase(id);
}
void VehicleSystem::prePhysicsUpdate(float deltaTime)
{
(void)deltaTime;
if (!m_physics)
return;
m_query.each([&](flecs::entity entity, VehicleComponent &vehicle,
RigidBodyComponent &rigidBody,
TransformComponent &) {
/* The chassis body went away (disabled, rebuilt, scene
* teardown): the constraint references the old body, drop
* it. */
if (vehicle.constraintCreated &&
(!rigidBody.bodyCreated ||
vehicle.constraint->GetVehicleBody()->GetID() !=
rigidBody.bodyID)) {
destroyConstraint(vehicle);
}
if (!vehicle.constraintCreated) {
if (rigidBody.bodyCreated && rigidBody.enabled)
createConstraint(entity, vehicle,
rigidBody);
return;
}
/* Driver input with the sample's brake-vs-reverse
* logic: a direction change request while still rolling
* the other way becomes a brake input until nearly
* stopped. */
float forward = vehicle.inputForward;
float brake = vehicle.inputBrake;
float handbrake = vehicle.inputHandBrake;
float &prevForward = m_prevForward[entity.id()];
if (prevForward * forward < 0.0f) {
float velocity = m_physics->getVehicleForwardSpeed(
vehicle.constraint);
if ((forward > 0.0f && velocity < -0.1f) ||
(forward < 0.0f && velocity > 0.1f)) {
forward = 0.0f;
brake = 1.0f;
} else {
prevForward = forward;
}
}
if (handbrake != 0.0f)
forward = 0.0f;
m_physics->setVehicleInput(vehicle.constraint, forward,
vehicle.inputRight, brake,
handbrake);
});
}
void VehicleSystem::postPhysicsUpdate()
{
if (!m_physics)
return;
m_query.each([&](flecs::entity entity, VehicleComponent &vehicle,
RigidBodyComponent &, TransformComponent &transform) {
if (!vehicle.constraintCreated || !transform.node)
return;
auto it = m_wheelVisuals.find(entity.id());
if (it == m_wheelVisuals.end())
return;
Ogre::Quaternion invChassis =
transform.node->_getDerivedOrientation().Inverse();
for (size_t i = 0; i < it->second.nodes.size(); i++) {
Ogre::Vector3 pos;
Ogre::Quaternion ori;
m_physics->getWheelWorldTransform(
vehicle.constraint, (int)i, pos, ori);
Ogre::SceneNode *node = it->second.nodes[i];
node->setPosition(transform.node->convertWorldToLocalPosition(
pos));
node->setOrientation(invChassis * ori);
}
});
}
@@ -0,0 +1,63 @@
#ifndef EDITSCENE_VEHICLESYSTEM_HPP
#define EDITSCENE_VEHICLESYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <map>
#include <vector>
#include "../physics/physics.h"
#include "../components/Vehicle.hpp"
#include "../components/RigidBody.hpp"
#include "../components/Transform.hpp"
/**
* Vehicle system (F10, see demos/demo-sokoban/PLAN.md).
*
* Owns the Jolt VehicleConstraint of every entity with a
* VehicleComponent + dynamic RigidBodyComponent (the chassis):
*
* - prePhysicsUpdate() (call BEFORE EditorPhysicsSystem::update):
* attaches the constraint once the chassis body exists, re-attaches
* it when the body is rebuilt, and feeds the component's driver
* input fields into the WheeledVehicleController;
* - postPhysicsUpdate() (call AFTER the physics step): syncs the
* per-wheel visual child nodes from the constraint state (when
* wheelMeshName is set).
*
* The chassis transform itself is synced by EditorPhysicsSystem like
* any other dynamic body.
*/
class VehicleSystem {
public:
VehicleSystem(flecs::world &world, JoltPhysicsWrapper *physics);
~VehicleSystem();
void prePhysicsUpdate(float deltaTime);
void postPhysicsUpdate();
private:
struct WheelVisual {
std::vector<Ogre::SceneNode *> nodes;
std::vector<Ogre::Entity *> entities;
};
void createConstraint(flecs::entity entity, VehicleComponent &vehicle,
RigidBodyComponent &rigidBody);
void destroyConstraint(VehicleComponent &vehicle);
void createWheelVisuals(flecs::entity entity,
VehicleComponent &vehicle);
void destroyWheelVisuals(flecs::entity_t id);
flecs::world &m_world;
JoltPhysicsWrapper *m_physics;
flecs::query<VehicleComponent, RigidBodyComponent,
TransformComponent> m_query;
flecs::entity m_removeObserver;
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;
};
#endif // EDITSCENE_VEHICLESYSTEM_HPP
@@ -462,6 +462,13 @@ static int testDoorOpenStatePersisted()
if (!DoorSystem::declareDoorDefaults(id, false))
FAIL("persisted open state not returned on rebuild");
// Scene-switch doors always reopen closed: the persisted open
// state is ignored and the store key is reset.
if (DoorSystem::declareDoorDefaults(id, false, true))
FAIL("scene-switch door restored open");
if (store.getBool(openKey))
FAIL("scene-switch door did not reset the stored open state");
PASS();
return 0;
}
+105
View File
@@ -0,0 +1,105 @@
#include "VehicleEditor.hpp"
#include <imgui.h>
bool VehicleEditor::renderComponent(flecs::entity entity,
VehicleComponent &vehicle)
{
bool modified = false;
if (ImGui::CollapsingHeader("Vehicle",
ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Indent();
if (ImGui::DragFloat("Max Torque", &vehicle.maxTorque, 1.0f,
0.0f, 10000.0f))
modified = true;
if (ImGui::DragFloat("Max Pitch/Roll (deg)",
&vehicle.maxPitchRollAngleDeg, 0.5f,
0.0f, 90.0f))
modified = true;
if (ImGui::DragFloat3("Seat Offset",
&vehicle.seatOffset.x, 0.01f))
modified = true;
char wheelMeshBuf[256];
snprintf(wheelMeshBuf, sizeof(wheelMeshBuf), "%s",
vehicle.wheelMeshName.c_str());
if (ImGui::InputText("Wheel Mesh", wheelMeshBuf,
sizeof(wheelMeshBuf))) {
vehicle.wheelMeshName = wheelMeshBuf;
modified = true;
}
ImGui::Separator();
ImGui::Text("Wheels (%d)", (int)vehicle.wheels.size());
ImGui::SameLine();
if (ImGui::SmallButton("Add")) {
vehicle.wheels.push_back(VehicleWheel());
modified = true;
}
int removeWheel = -1;
for (size_t i = 0; i < vehicle.wheels.size(); i++) {
VehicleWheel &w = vehicle.wheels[i];
ImGui::PushID((int)i);
Ogre::String label =
"Wheel " + std::to_string(i);
if (ImGui::TreeNode(label.c_str())) {
if (ImGui::DragFloat3("Position",
&w.position.x, 0.01f))
modified = true;
if (ImGui::DragFloat("Radius", &w.radius,
0.005f, 0.05f, 2.0f))
modified = true;
if (ImGui::DragFloat("Width", &w.width, 0.005f,
0.02f, 1.0f))
modified = true;
if (ImGui::DragFloat("Susp. Min",
&w.suspensionMinLength,
0.005f, 0.0f, 3.0f))
modified = true;
if (ImGui::DragFloat("Susp. Max",
&w.suspensionMaxLength,
0.005f, 0.0f, 3.0f))
modified = true;
if (ImGui::DragFloat("Susp. Freq",
&w.suspensionFrequency,
0.01f, 0.1f, 10.0f))
modified = true;
if (ImGui::DragFloat("Susp. Damping",
&w.suspensionDamping,
0.01f, 0.0f, 2.0f))
modified = true;
if (ImGui::DragFloat("Max Steer (deg)",
&w.maxSteerAngleDeg, 0.5f,
0.0f, 90.0f))
modified = true;
if (ImGui::Checkbox("Driven", &w.driven))
modified = true;
if (ImGui::DragFloat("Handbrake Torque",
&w.maxHandBrakeTorque,
1.0f, 0.0f, 10000.0f))
modified = true;
if (ImGui::SmallButton("Remove"))
removeWheel = (int)i;
ImGui::TreePop();
}
ImGui::PopID();
}
if (removeWheel >= 0) {
vehicle.wheels.erase(vehicle.wheels.begin() +
removeWheel);
modified = true;
}
ImGui::Separator();
ImGui::Text("Status: %s",
vehicle.constraintCreated ?
"constraint active" :
"no constraint");
ImGui::Unindent();
}
return modified;
}
@@ -0,0 +1,18 @@
#ifndef EDITSCENE_VEHICLEEDITOR_HPP
#define EDITSCENE_VEHICLEEDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/Vehicle.hpp"
/**
* Editor for VehicleComponent (F10)
*/
class VehicleEditor : public ComponentEditor<VehicleComponent> {
public:
bool renderComponent(flecs::entity entity,
VehicleComponent &vehicle) override;
const char *getName() const override { return "Vehicle"; }
};
#endif // EDITSCENE_VEHICLEEDITOR_HPP