diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index b31d122..79d4bfd 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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) @@ -883,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: diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 815fd43..c9a749c 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 2ef4dae..d38eb7f 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -7,6 +7,7 @@ #include "GameMode.hpp" #include #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( + m_world, m_physicsSystem->getPhysicsWrapper()); + // Setup buoyancy system (requires physics system) // Get the physics wrapper from the physics system m_buoyancySystem = std::make_unique( @@ -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(); diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 4d3cb37..52e4353 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -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 m_startupMenuSystem; std::unique_ptr m_playerControllerSystem; + /* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */ + std::unique_ptr 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); diff --git a/src/features/editScene/components/Vehicle.hpp b/src/features/editScene/components/Vehicle.hpp new file mode 100644 index 0000000..f00c766 --- /dev/null +++ b/src/features/editScene/components/Vehicle.hpp @@ -0,0 +1,76 @@ +#ifndef EDITSCENE_VEHICLE_HPP +#define EDITSCENE_VEHICLE_HPP +#pragma once + +#include +#include +#include + +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 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 diff --git a/src/features/editScene/components/VehicleModule.cpp b/src/features/editScene/components/VehicleModule.cpp new file mode 100644 index 0000000..c744d72 --- /dev/null +++ b/src/features/editScene/components/VehicleModule.cpp @@ -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( + "Vehicle", "Physics", std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set(makeDefaultVehicle()); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/demos/demo-sokoban/CMakeLists.txt b/src/features/editScene/demos/demo-sokoban/CMakeLists.txt new file mode 100644 index 0000000..0362731 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/CMakeLists.txt @@ -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 /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// 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" +) diff --git a/src/features/editScene/demos/demo-sokoban/PLAN.md b/src/features/editScene/demos/demo-sokoban/PLAN.md new file mode 100644 index 0000000..d22e3eb --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/PLAN.md @@ -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..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..*` 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 + (`.pad`), 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..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). diff --git a/src/features/editScene/demos/demo-sokoban/demo_main.cpp b/src/features/editScene/demos/demo-sokoban/demo_main.cpp new file mode 100644 index 0000000..6adba0a --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/demo_main.cpp @@ -0,0 +1,1356 @@ +#include +#include +#include "EditorApp.hpp" +#include "ProjectConfig.hpp" +#include "camera/EditorCamera.hpp" +#include "systems/CharacterRegistry.hpp" +#include "systems/DoorSystem.hpp" +#include "systems/PlayerControllerSystem.hpp" +#include "systems/TerrainSystem.hpp" +#include "systems/EventBus.hpp" +#include "systems/GlobalStateStore.hpp" +#include "components/Door.hpp" +#include "components/EntityName.hpp" +#include "components/Transform.hpp" +#include "components/RigidBody.hpp" +#include "components/Vehicle.hpp" +#include "systems/PhysicsSystem.hpp" +#include +#include +#include +#include +#include +#include + +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT +#include "project.h" +#endif + +struct ExitAfterFirstFrameListener : public Ogre::FrameListener { + Ogre::Root *root; + bool triggered = false; + ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {} + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + if (!triggered) { + triggered = true; + root->queueEndRendering(); + } + return true; + } +}; + +/* Debug capture aid (for offscreen/xvfb screenshots): + * --force-pos x y z yawDeg [pitchDeg] + * teleport the player character once, as + * soon as it is alive (physics picks the + * new position up through the usual + * node->physics sync, gravity/buoyancy + * then act naturally); yaw/pitch (degrees) + * are re-applied to the TPS camera every + * frame via PlayerControllerSystem; + * --screenshot N path write frame N of the render window to + * path (RenderWindow::writeContentsToFile) + * and quit. + * --debug-buoyancy enable BuoyancySystem debug logging (same + * as the editor's --debug-buoyancy). */ +struct DebugCaptureListener : public Ogre::FrameListener { + EditorApp *app; + bool teleportEnabled = false; + bool teleported = false; + Ogre::Vector3 teleportPos = Ogre::Vector3::ZERO; + float teleportYawDeg = 0.0f; + float teleportPitchDeg = 0.0f; + int shotFrame = -1; + Ogre::String shotFile; + int frame = 0; + + DebugCaptureListener(EditorApp *a) : app(a) {} + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (teleportEnabled) { + flecs::entity player = app->getPlayerCharacterEntity(); + if (player.is_alive() && + player.has()) { + TransformComponent &t = + player.get_mut(); + if (t.node) { + /* Re-teleport while the character is below + * the terrain surface at the target: the + * streaming terrain colliders may not be + * built yet on the first frames, so the + * character can fall through. Once the + * collider exists it stands (or floats in + * deep water) at/above the surface and the + * teleport stops. */ + bool fellThrough = false; + TerrainSystem *ts = + TerrainSystem::getInstance(); + if (teleported && ts && ts->isActive()) { + float ground = ts->getHeightAt( + teleportPos); + fellThrough = + t.node->getPosition().y < + ground - 0.5f; + } + if (!teleported || fellThrough) { + t.node->setPosition(teleportPos); + t.node->setOrientation(Ogre::Quaternion( + Ogre::Degree(teleportYawDeg), + Ogre::Vector3::UNIT_Y)); + teleported = true; + } + } + } + } + /* The TPS camera yaw lives in PlayerControllerSystem state, + * not in the character node orientation, so re-apply the + * requested yaw/pitch every frame once teleported. */ + if (teleported && app->getPlayerControllerSystem()) + app->getPlayerControllerSystem()->setControllerYawPitch( + teleportYawDeg, teleportPitchDeg); + if (shotFrame > 0 && frame >= shotFrame) { + app->getRenderWindow()->writeContentsToFile(shotFile); + app->getRoot()->queueEndRendering(); + } + return true; + } +}; + +/* Same application as the editor/game, but with a larger initial window. */ +class DemoApp : public EditorApp { +public: + using EditorApp::EditorApp; + + OgreBites::NativeWindowPair + createWindow(const Ogre::String &name, uint32_t w, uint32_t h, + Ogre::NameValuePairList miscParams) override + { + (void)w; + (void)h; + return EditorApp::createWindow(name, 1920, 1080, miscParams); + } +}; + +/* + * The demo floor mesh and its flat colored material are created + * programmatically because RenderableComponent only references a mesh by + * name and carries no material/color of its own. Only the interior scene + * needs one (the green "DemoFloorPlaneInterior"); the exterior scene + * walks on the terrain instead. + */ +static void createFloorMesh(const Ogre::String &meshName, + const Ogre::String &materialName, + float dr, float dg, float db) +{ + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + + if (!Ogre::MeshManager::getSingleton() + .getByName(meshName, group) + .isNull()) + return; + + Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create( + materialName, group); + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + pass->setDiffuse(dr, dg, db, 1.0f); + pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f); + pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f); + + /* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider + * (top surface at y = 0) on the "demo_floor" entity in the scenes. */ + Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f); + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane( + meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1, + 1.0f, 1.0f, Ogre::Vector3::UNIT_Z); + mesh->getSubMesh(0)->setMaterialName(materialName); +} + +static void createDemoResources() +{ + /* Interior scene: green floor. The exterior scene has no floor + * mesh — the terrain provides the ground. */ + createFloorMesh("DemoFloorPlaneInterior", "DemoFloorMaterialInterior", + 0.35f, 0.5f, 0.35f); +} + +/* + * End-to-end check for --test-switch: drives the same path an E-press on + * a scene-switch door takes (DoorComponent toggleRequested + + * sceneSwitchPending -> DoorSystem swing -> EditorApp::switchScene() + * queue once the leaf is fully open -> performSceneSwitch() on the next + * frame with the "@arrival_*" teleport), for a full + * interior -> exterior -> interior round trip: the interior scene exits + * through the "interrior" grid's door Z:0:0:15 and the exterior scene + * returns through its own grid's door Z:0:0:0. The prompt/targeting + * glue is screen-space and therefore not covered headless. + */ +struct SceneSwitchTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 10; /* 10-12: F6 locked door, then 0-3 scene switching */ + bool failed = false; + Ogre::String failReason; + + /* F6 demo door: internal doorway Z:0:0:8 of the "interrior" grid, + * lockable + locked by default (doorConfigs in + * demo_scene_interior.json); the inline scene script unlocks it on + * the first door_locked bump. */ + const Ogre::String doorId = + "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8"; + + /* F1 demo doors: the interior scene exits through the external + * doorway Z:0:0:15 of the "interrior" grid (scene-switch door to + * demo_scene_exterior.json), the exterior scene returns through the + * external doorway Z:0:0:0 of its own exteriorOnly grid + * (scene-switch door back to demo_scene_interior.json). For both + * legs the switch must fire only when the leaf is fully open. */ + const Ogre::String exitDoorId = + "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:15"; + const Ogre::String returnDoorId = + "77370b1d-e8ab-44a6-865b-e55c15b0fc78:Z:0:0:0"; + int exitOpenWait = 0; + int returnOpenWait = 0; + int arrivalDeadline = 0; + + /* arrival_b in the exterior scene: 3 units outside (-Z of) the + * exteriorOnly grid's return door Z:0:0:0 near the streaming-world + * center. The expected Y is the terrain surface height there, read + * back from the engine (streaming terrain: base heights come from + * baseNoise, so no flat Y can be hardcoded). */ + Ogre::Vector3 expectedArrivalB() const + { + Ogre::Vector3 expected(4000.6f, 0.0f, 3995.9f); + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->isActive()) + expected.y = ts->getHeightAt(ts->worldToRender( + expected.x, 0.0, expected.z)); + return expected; + } + + SceneSwitchTestListener(EditorApp *a) + : app(a) + { + } + + bool entityExists(const char *name) + { + bool found = false; + app->getWorld()->query().each( + [&](flecs::entity, EntityNameComponent &n) { + if (n.name == name) + found = true; + }); + return found; + } + + /* The F6 demo door entity (recreated on every scene load). */ + flecs::entity findDoor() + { + return findDoorById(doorId); + } + + flecs::entity findDoorById(const Ogre::String &id) + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, DoorComponent &d) { + if (d.doorId == id) + result = e; + }); + return result; + } + + /* Emulate the E-press on a scene-switch door. */ + bool requestDoorSwitch(const Ogre::String &id) + { + flecs::entity door = findDoorById(id); + if (!door.is_alive()) + return false; + DoorComponent &d = door.get_mut(); + if (!d.occluder) { + failed = true; + failReason = "F1 scene-switch door has no occluder"; + return true; + } + d.toggleRequested = true; + d.sceneSwitchPending = true; + return true; + } + + /* Waits for the queued scene switch of a scene-switch door; + * returns true once the switch to expectPath is pending (the door + * must be fully open by then). */ + bool waitDoorSwitch(const Ogre::String &id, const Ogre::String &label, + const char *expectPath, int &openWait) + { + flecs::entity door = findDoorById(id); + if (!door.is_alive()) + return false; + const DoorComponent &d = door.get(); + bool fullyOpen = d.isOpen && d.currentAngle == d.openAngle; + if (app->hasPendingSceneSwitch()) { + if (!fullyOpen) { + failed = true; + failReason = + "F1 scene switch fired before the door was fully open"; + return true; + } + if (app->getPendingSceneSwitchPath() != expectPath) { + failed = true; + failReason = "F1 wrong pending switch path"; + return true; + } + std::cout << "[test] F1 " << label + << " fully open, switch queued" << std::endl; + return true; + } + if (fullyOpen) { + /* Frame-listener ordering grace. */ + if (++openWait > 5) { + failed = true; + failReason = "F1 scene switch never fired"; + } + } else { + openWait = 0; + } + return false; + } + + bool playerPos(Ogre::Vector3 &out) const + { + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive() || !player.has()) + return false; + + const TransformComponent &t = player.get(); + out = t.node ? t.node->_getDerivedPosition() : t.position; + return true; + } + + bool checkPlayerNear(const Ogre::Vector3 &expected, float tol) + { + Ogre::Vector3 pos; + if (!playerPos(pos)) + return false; + if (pos.distance(expected) > tol) { + failed = true; + failReason = "player not at arrival point: (" + + Ogre::StringConverter::toString(pos.x) + + ", " + + Ogre::StringConverter::toString(pos.y) + + ", " + + Ogre::StringConverter::toString(pos.z) + + ")"; + } + return true; + } + + /* The arrival markers face -Z (both scenes keep the same facing + * convention; the exterior arrival_b at (4000.6, ~, 3995.9) faces + * away from the return door); verify the camera took a position + * behind the character (relative to charPos) looking at the walking + * surface, and that the character's visual facing (local +Z) points + * the same way. */ + bool checkCameraFacesCenter(const Ogre::Vector3 &charPos) + { + flecs::entity player = app->getPlayerCharacterEntity(); + if (!player.is_alive() || !player.has()) + return false; + + const TransformComponent &t = player.get(); + Ogre::Quaternion q = + t.node ? t.node->getOrientation() : t.rotation; + Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z; + + EditorCamera *ec = app->getEditorCamera(); + Ogre::Camera *cam = ec ? ec->getCamera() : nullptr; + Ogre::SceneNode *camNode = + cam ? cam->getParentSceneNode() : nullptr; + if (!camNode) + return false; + Ogre::Vector3 cp = camNode->_getDerivedPosition(); + Ogre::Vector3 vd = camNode->_getDerivedOrientation() * + Ogre::Vector3::NEGATIVE_UNIT_Z; + if (vd.z > -0.9f || cp.z < charPos.z + 1.0f || + charFacing.z > -0.9f) { + failed = true; + failReason = + "not facing the walking surface: camera pos (" + + Ogre::StringConverter::toString(cp.x) + ", " + + Ogre::StringConverter::toString(cp.y) + ", " + + Ogre::StringConverter::toString(cp.z) + + ") view (" + + Ogre::StringConverter::toString(vd.x) + ", " + + Ogre::StringConverter::toString(vd.y) + ", " + + Ogre::StringConverter::toString(vd.z) + + ") character facing (" + + Ogre::StringConverter::toString(charFacing.x) + + ", " + + Ogre::StringConverter::toString(charFacing.z) + + ")"; + } + return true; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 3) { + app->getRoot()->queueEndRendering(); + return true; + } + /* The swings are real-time (doorOpenSpeed deg/s) while a + * headless frame is sub-millisecond, so the door legs need a + * few thousand frames (~10 s wall clock at 1000+ fps); the + * streaming terrain window and the grounding watchdog add a + * few hundred frames on top. */ + if (frame > 20000) { + failed = true; + failReason = "timeout waiting for scene switch"; + app->getRoot()->queueEndRendering(); + return true; + } + + switch (phase) { + case 10: { + /* F6: the demo door starts locked (lockedByDefault). */ + if (frame < 5) + break; + flecs::entity door = findDoor(); + if (!door.is_alive()) { + if (frame > 60) { + failed = true; + failReason = "F6 demo door not found"; + } + break; + } + if (!DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 demo door not locked at start"; + break; + } + /* Emulate the ActuatorSystem E-press on a locked door; + * the inline scene script answers door_locked with + * door_unlock_. */ + EventBus::getInstance().send("door_locked", "door_id", + doorId); + phase = 11; + break; + } + case 11: { + /* F6: wait for the scene script to unlock the door + * (the frame timeout covers a missing handler). */ + if (DoorSystem::isDoorLockedById(doorId)) + break; + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + door.get_mut().toggleRequested = true; + std::cout << "[test] F6 door unlocked via scene script, " + "opening" + << std::endl; + phase = 12; + break; + } + case 12: { + /* F6: the swing completes and the open state lands in + * the global store. */ + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + const DoorComponent &d = door.get(); + if (!d.isOpen || d.currentAngle != d.openAngle) + break; + if (!GlobalStateStore::getInstance().getBool( + "door." + doorId + ".isOpen")) { + failed = true; + failReason = "F6 door open state not persisted"; + break; + } + std::cout << "[test] F6 door open and persisted" + << std::endl; + phase = 0; + break; + } + case 0: { + /* F1: interior -> exterior through the exit scene-switch + * door. */ + if (frame < 5) + break; + if (!findDoorById(exitDoorId).is_alive()) { + if (frame > 60) { + failed = true; + failReason = "F1 exit door not found"; + } + 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(); + 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; + break; + } + case 13: { + /* F1: the scene switch must fire only once the leaf + * is fully open. */ + if (waitDoorSwitch(exitDoorId, "exit door", + "demo_scene_exterior.json", + exitOpenWait)) + phase = 1; + break; + } + case 1: { + if (!entityExists("x1")) + break; + if (!arrivalDeadline) + arrivalDeadline = frame + 600; + /* The teleport clamps Y against whatever height source is + * available at switch time (analytic baseNoise fallback + * until the pages around the camera stream in), then the + * grounding watchdog re-clamps against the real terrain + * for ~120 frames; poll across frames until the player + * settles at the arrival point instead of checking once. */ + Ogre::Vector3 expected = expectedArrivalB(); + Ogre::Vector3 pos; + if (!playerPos(pos)) + break; + if (pos.distance(expected) > 1.0f) { + if (frame < arrivalDeadline) + break; + failed = true; + failReason = + "player not at arrival point: (" + + Ogre::StringConverter::toString(pos.x) + + ", " + + Ogre::StringConverter::toString(pos.y) + + ", " + + Ogre::StringConverter::toString(pos.z) + + "), expected (" + + Ogre::StringConverter::toString( + expected.x) + + ", " + + Ogre::StringConverter::toString( + expected.y) + + ", " + + Ogre::StringConverter::toString( + expected.z) + + ")"; + break; + } + if (!checkCameraFacesCenter(expected)) + break; + if (failed) + break; + /* The island site must be dry: terrain height at the + * grid XZ above the water surface (Y 6). Doubles as an + * in-engine cross-check of the offline baseNoise mapping + * used to place the exterior content. */ + { + TerrainSystem *ts = TerrainSystem::getInstance(); + float h = ts && ts->isActive() + ? ts->getHeightAt( + ts->worldToRender(4000.0, + 0.0, + 4000.0)) + : 0.0f; + if (h <= 6.0f) { + failed = true; + failReason = + "island site is not dry"; + break; + } + } + std::cout << "[test] arrived in the exterior scene at " + "arrival_b, camera faces the walking surface" + << std::endl; + /* F1: exterior -> interior through the exterior + * scene's own scene-switch door (external doorway + * Z:0:0:0 of its exteriorOnly grid). */ + if (!findDoorById(returnDoorId).is_alive()) { + failed = true; + failReason = "F1 return door not found"; + break; + } + if (!requestDoorSwitch(returnDoorId)) + break; + phase = 14; + break; + } + case 14: + /* F1: same fully-open contract on the way back. */ + if (waitDoorSwitch(returnDoorId, "return door", + "demo_scene_interior.json", + returnOpenWait)) + phase = 2; + break; + case 2: + if (!entityExists("interrior")) + break; + if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f), + 1.0f)) + break; + if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f, + 24.0f))) + break; + /* F6: the door rebuilt with the scene must be snapped + * back to the persisted open + unlocked state. */ + { + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + const DoorComponent &d = + door.get(); + if (!d.isOpen || d.currentAngle != d.openAngle || + DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = + "F6 door state not restored after scene switch"; + break; + } + std::cout << "[test] F6 door state restored " + "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(); + 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 " + "walking surface" + << std::endl; + std::cout << "[test] PASS" << std::endl; + } + phase = 3; + break; + } + return true; + } +}; + +/* + * F10 end-to-end check for --test-vehicle: loads the exterior scene, + * waits for the "forklift1" entity's chassis body + vehicle constraint + * to be created and to settle on the streaming terrain, then drives it + * forward through the VehicleComponent input fields and checks that it + * moved, stayed upright and stops again after the input is released. + * Headless frames are sub-millisecond while physics advances in real + * time, so the drive phase spans a few thousand frames. + */ +struct VehicleTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; /* 0: wait spawn, 1: wait settle, 2: drive, 3: stop */ + bool failed = false; + Ogre::String failReason; + Ogre::Vector3 startPos = Ogre::Vector3::ZERO; + Ogre::Vector3 authoredPos = Ogre::Vector3::ZERO; + bool authoredValid = false; + float lastY = 0.0f; + int settleFrames = 0; + int driveStart = 0; + + VehicleTestListener(EditorApp *a) + : app(a) + { + } + + flecs::entity findForklift() + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, EntityNameComponent &n) { + if (n.name == "forklift1") + result = e; + }); + return result; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + frame++; + if (failed || phase == 4) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > 40000) { + failed = true; + failReason = "timeout in vehicle test"; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity forklift = findForklift(); + if (!forklift.is_alive()) { + if (frame > 600) { + failed = true; + failReason = "forklift1 entity not found"; + } + return true; + } + + /* Grounding watchdog: the streaming terrain colliders are + * not built yet on the first frames, so the chassis can fall + * through the surface (same fallback as DebugCaptureListener + * uses for the player character). Re-teleport the body back + * to its authored spawn pose until the collider catches it. */ + if (phase <= 1 && forklift.has() && + forklift.has()) { + const RigidBodyComponent &rb = + forklift.get(); + const TransformComponent &t = + forklift.get(); + if (!authoredValid) { + authoredPos = t.position; + authoredValid = true; + } + if (rb.bodyCreated && t.node) { + TerrainSystem *ts = TerrainSystem::getInstance(); + if (ts && ts->isActive()) { + Ogre::Vector3 pos = + t.node->_getDerivedPosition(); + float ground = ts->getHeightAt(pos); + float stray = Ogre::Vector2( + pos.x - authoredPos.x, + pos.z - authoredPos.z) + .length(); + if (pos.y < ground - 0.5f || stray > 2.0f) { + std::cout << "[test] watchdog " + "teleport frame=" + << frame << " from (" + << pos.x << ", " << pos.y + << ", " << pos.z + << ") ground=" << ground + << " stray=" << stray + << std::endl; + /* Reset position, rotation and + * velocity: re-dropping a tumbling + * chassis just re-tumbles it. */ + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + phys->setPositionAndRotation( + rb.bodyID, + Ogre::Vector3( + authoredPos.x, + ground + 1.0f, + authoredPos.z), + Ogre::Quaternion::IDENTITY); + phys->getPhysicsSystem() + ->GetBodyInterface() + .SetLinearAndAngularVelocity( + rb.bodyID, + JPH::Vec3::sZero(), + JPH::Vec3::sZero()); + settleFrames = 0; + lastY = ground + 1.0f; + return true; + } + } + } + } + + switch (phase) { + case 0: { + /* Wait for the chassis body + vehicle constraint. */ + if (!forklift.has() || + !forklift.has()) + break; + const RigidBodyComponent &rb = + forklift.get(); + const VehicleComponent &v = + forklift.get(); + if (!rb.bodyCreated || !v.constraintCreated) + break; + if (forklift.has()) { + const TransformComponent &t = + forklift.get(); + startPos = t.node ? t.node->_getDerivedPosition() : + t.position; + lastY = startPos.y; + } + std::cout << "[test] forklift1 constraint created at (" + << startPos.x << ", " << startPos.y << ", " + << startPos.z << ")" << std::endl; + phase = 1; + break; + } + case 1: { + /* Wait until the chassis genuinely rests on the + * terrain: upright, slow and Y-stable for a while + * (Y-stability alone fools easily on headless frames + * shorter than a physics step). */ + const TransformComponent &t = + forklift.get(); + const RigidBodyComponent &rb = + forklift.get(); + JoltPhysicsWrapper *phys = app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + float speed = + phys->getLinearVelocity(rb.bodyID).length(); + Ogre::Quaternion q = t.node ? t.node->_getDerivedOrientation() : + t.rotation; + float upY = (q * Ogre::Vector3::UNIT_Y).y; + float y = t.node ? t.node->_getDerivedPosition().y : + t.position.y; + if (speed < 0.1f && upY > 0.95f && + fabs(y - lastY) < 0.01f) { + if (++settleFrames > 60) { + startPos = t.node ? + t.node->_getDerivedPosition() : + t.position; + std::cout << "[test] forklift1 settled " + "at y=" + << y << " upY=" << upY + << std::endl; + driveStart = frame; + phase = 2; + } + } else { + settleFrames = 0; + } + lastY = y; + break; + } + case 2: { + /* Drive forward. */ + forklift.get_mut().inputForward = 1.0f; + int driven = frame - driveStart; + if (driven % 60 == 0) { + const VehicleComponent &v = + forklift.get(); + const RigidBodyComponent &rb = + forklift.get(); + const TransformComponent &t = + forklift.get(); + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + float spd = phys->getVehicleForwardSpeed( + v.constraint); + Ogre::Vector3 bpos = phys->getPosition(rb.bodyID); + Ogre::Vector3 npos = + t.node ? t.node->_getDerivedPosition() : + t.position; + TerrainSystem *ts = TerrainSystem::getInstance(); + float ground = ts && ts->isActive() ? + ts->getHeightAt(npos) : + -1.0f; + std::cout << "[test] drive frames=" << driven + << " fwdSpeed=" << spd + << " bodyPos=(" << bpos.x << ", " << bpos.y + << ", " << bpos.z << ") nodePos=(" << npos.x + << ", " << npos.y << ", " << npos.z + << ") ground=" << ground << " active=" + << phys->isActive(rb.bodyID) << std::endl; + } + if (driven > 180) { + const TransformComponent &t = + forklift.get(); + Ogre::Vector3 pos = + t.node ? t.node->_getDerivedPosition() : + t.position; + float moved = (pos - startPos).length(); + /* Forward is local +Z (identity rotation at + * spawn): most movement should be +Z. */ + float movedZ = pos.z - startPos.z; + Ogre::Quaternion q = + t.node ? t.node->_getDerivedOrientation() : + t.rotation; + Ogre::Vector3 up = q * Ogre::Vector3::UNIT_Y; + if (moved < 2.0f || movedZ < 1.0f) { + failed = true; + failReason = + "forklift1 did not drive forward " + "(moved " + + Ogre::StringConverter::toString( + moved) + + ", dz " + + Ogre::StringConverter::toString( + movedZ) + + ")"; + break; + } + if (up.y < 0.7f) { + failed = true; + failReason = + "forklift1 tipped over (up.y " + + Ogre::StringConverter::toString( + up.y) + + ")"; + break; + } + std::cout << "[test] forklift1 drove " << moved + << " units (dz " << movedZ << ")" + << std::endl; + forklift.get_mut() + .inputForward = 0.0f; + driveStart = frame; + phase = 3; + } + break; + } + case 3: { + /* Brake to a stop after releasing the throttle. */ + forklift.get_mut().inputBrake = 1.0f; + if ((frame - driveStart) % 60 == 0 && + forklift.has() && + forklift.has()) { + const RigidBodyComponent &rb = + forklift.get(); + const VehicleComponent &v = + forklift.get(); + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + std::cout << "[test] brake frames=" + << frame - driveStart << " fwdSpeed=" + << phys->getVehicleForwardSpeed( + v.constraint) + << " active=" + << phys->isActive(rb.bodyID) << std::endl; + } + if (frame - driveStart < 500) + break; + const TransformComponent &t = + forklift.get(); + float y0 = t.node ? t.node->_getDerivedPosition().y : + t.position.y; + float x0 = t.node ? t.node->_getDerivedPosition().x : + t.position.x; + float z0 = t.node ? t.node->_getDerivedPosition().z : + t.position.z; + if (fabs(y0 - lastY) < 0.001f) { + settleFrames++; + } else { + settleFrames = 0; + } + lastY = y0; + if (settleFrames > 100) { + const RigidBodyComponent &rb = + forklift.get(); + JoltPhysicsWrapper *phys = app + ->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + float speed = phys->getLinearVelocity(rb.bodyID) + .length(); + if (speed > 0.5f) { + failed = true; + failReason = + "forklift1 did not stop (speed " + + Ogre::StringConverter::toString( + speed) + + ")"; + break; + } + std::cout << "[test] forklift1 stopped at (" + << x0 << ", " << y0 << ", " << z0 + << ")" << std::endl; + std::cout << "[test] PASS" << std::endl; + phase = 4; + } + break; + } + } + return true; + } +}; + +/* + * demo-sokoban: starts as an exact copy of + * demos/demo-interior-exterior-dynamics; a forklift sokoban activity + * (drivable vehicle, pushable crates, target pads, completion HUD) is + * added to demo_scene_exterior.json step by step - see PLAN.md in this + * directory. The inherited scene setup: two + * hand-authored scenes (demo_scene_interior.json and + * demo_scene_exterior.json), each with a character spawner ("s1", + * character registry ID 2, same character setup as town8.json) and a + * PlayerControllerComponent targeting it. + * + * The interior scene holds a flat colored floor plane with a static + * physics collider plus an "interrior" entity with a CellGridComponent + * in interiorOnly generation mode (a room with floor, ceiling, interior + * walls, windows with opaque glass and an exit door) carrying its own + * ProceduralMaterial + ProceduralTexture; the grid's texture rectangle + * names reference the texture's named rects ("floor" / "ceiling") + * without any Lot/District/Town parent. + * + * The exterior scene holds an exteriorOnly CellGridComponent (entity + * "x1"/"w1" - just the building shell with opaque window glass, seen + * from the outside) standing on a STREAMING terrain entity + * (streamingEnabled, worldSizeUnits 10000 -> 5x5 pages of 2000 units, + * base heights from TerrainComponent::baseNoise: OpenSimplex2 seed 55, + * 3 octaves, frequency 0.0003, amplitude 15 - an archipelago of large + * islands, ~34% land, the rest is ocean). The bounded streaming world + * spans [-1000, 9000] on both axes around the terrain entity at the + * origin (world (0,0) is half a page in from the corner, NOT the world + * center), so all exterior content is placed near the world center on + * an island with more than 500 units of dry land in every direction: + * the grid at (4000, 10.0148, 4000) with its floor exactly on the + * terrain surface, arrival_b at (4000.6, 9.9905, 3995.9) 3 units + * outside (-Z of) the return door Z:0:0:0, spawner s1 at (4000, + * 9.9039, 3975) - all Y values probed from the engine's + * TerrainSystem::getHeightAt. The "sky" entity (skybox + sun + * components) sits at (4000, 10, 4000): EditorSunSystem parents the + * sun/moon sphere nodes to the root node and orbits them around the + * camera (orbit radius 200), so they always stay inside the + * camera-following skybox cube and line up with the shader sun/moon + * discs, but the light node stays a child of the sky entity's + * transform node, so the entity should still sit near the content for + * sensible shadow placement (town8.json keeps its sky entity at the + * origin next to its content). A "water" + * entity (waterPlane + waterPhysics components, the buoyancy setup of + * town8.json, planeSize 12000 centered on the content) completes the + * scene; the water surface at Y 6 turns everything below into ocean + * floor while the content island (terrain ~10 at the site) stays dry. + * The shipped heightmaps/4242424300000001/heightmap.bin (generated by + * gen_heightmap.py) is still staged - unused in streaming mode, it keeps + * flipping streamingEnabled off a working fallback. + * + * F6 demo content: the interior grid's internal doorway Z:0:0:8 is + * configured (doorConfigs in demo_scene_interior.json) as persistent + + * lockable + locked by default, with a fixed gridUid so its global door + * ID is stable, and the grid entity carries an inline scene script that + * answers the "door_locked" event with "door_unlock_" (the door + * event contract; the door never opens by itself, the player presses E + * again). The door's locked and open/closed state lives in the global + * state store and survives the interior -> exterior -> interior scene + * switches. + * + * F1 demo content: both scene transitions go through scene-switch doors. + * The interior grid has the external exit doorway Z:0:0:15 configured as + * a scene-switch door to demo_scene_exterior.json (target arrival_b); + * the exterior grid sets + * doorSceneSwitchPath/demo_scene_interior.json + + * doorSceneSwitchTarget/arrival_a grid-wide, so its external doorway + * 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; 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. + * + * The mouse is grabbed while Playing (built-in game-mode behaviour); + * Escape toggles the pause menu, which frees the cursor. + * + * The binary is self-contained in its build directory: run it from there + * (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the + * runtime config JSONs are staged next to it by the build; both scene + * JSONs are symlinks into the source tree, so scene edits take effect + * without a rebuild; the terrain heightmap is staged to + * heightmaps/4242424300000001/ by configure_file, re-copied automatically + * when the source file changes). + * + * Extra flags: + * --test-switch headless-friendly end-to-end check: verifies the F6 + * locked door (locked at start, unlocked by the scene + * script through the door event contract, open state + * 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 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 + * grounding watchdog re-clamps); exits 0 on PASS. + * --test-vehicle headless-friendly F10 vehicle check: loads the + * exterior scene directly, waits for the "forklift1" + * chassis body + vehicle constraint, lets it settle on + * the streaming terrain, then drives it forward through + * the VehicleComponent input fields and verifies it + * moved, stayed upright and stops again once the input + * is released; exits 0 on PASS. + */ +int main(int argc, char *argv[]) +{ + try { +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT + /* Release binary: project parameters are embedded at build + * time from project.json (generated project.h); the project + * root is the working directory the binary runs from. */ + DemoApp app(EDITSCENE_PROJECT_APP_NAME); + { + ProjectConfig cfg; + cfg.rootDir = + std::filesystem::current_path().string(); + cfg.appName = EDITSCENE_PROJECT_APP_NAME; + cfg.startScene = EDITSCENE_PROJECT_START_SCENE; + cfg.gameMode = EDITSCENE_PROJECT_GAME_MODE; + cfg.loaded = true; + app.setProjectConfig(cfg); + } +#else + DemoApp app; +#endif + app.setGameMode(EditorApp::GameMode::Game); + + bool headless = false; + bool exitAfterFirstFrame = false; + bool testSwitch = false; + bool testVehicle = false; + bool sceneArgGiven = false; + Ogre::String shotFile; + int shotFrame = -1; + bool forcePos = false; + Ogre::Vector3 forcePosVec = Ogre::Vector3::ZERO; + float forcePosYaw = 0.0f; + float forcePosPitch = 0.0f; +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT + Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE; +#else + Ogre::String sceneFile = "demo_scene_interior.json"; +#endif + for (int i = 1; i < argc; i++) { + Ogre::String arg = argv[i]; + if (arg == "--headless") { + headless = true; + } else if (arg == "--exit-after-first-frame") { + exitAfterFirstFrame = true; + } else if (arg == "--test-switch") { + testSwitch = true; + } else if (arg == "--test-vehicle") { + testVehicle = true; + } else if (arg == "--debug-buoyancy") { + app.setDebugBuoyancy(true); + } else if (arg == "--screenshot" && i + 2 < argc) { + shotFrame = atoi(argv[i + 1]); + shotFile = argv[i + 2]; + i += 2; + } else if (arg == "--force-pos" && i + 4 < argc) { + forcePos = true; + forcePosVec = Ogre::Vector3( + (float)atof(argv[i + 1]), + (float)atof(argv[i + 2]), + (float)atof(argv[i + 3])); + forcePosYaw = (float)atof(argv[i + 4]); + i += 4; + /* Optional pitch (degrees); only consumed when + * the next token is plainly numeric, so the + * positional scene file is never eaten. */ + if (i + 1 < argc) { + char *end = nullptr; + float p = (float)strtod(argv[i + 1], + &end); + if (end && *end == '\0' && + end != argv[i + 1]) { + forcePosPitch = p; + i += 1; + } + } + } else if (arg.length() > 0 && arg[0] != '-') { + sceneFile = arg; + sceneArgGiven = true; + } + } + /* The vehicle test drives the forklift parked in the exterior + * scene, so load that scene directly (unless the caller gave + * an explicit scene path). */ + if (testVehicle && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; + app.setHeadless(headless); + if (headless) { + /* Headless frames take ~1ms of wall time; pin a 60Hz + * frame delta so physics steps full fixed steps instead + * of tiny remainder slivers (soft vehicle suspension + * springs misbehave at millisecond deltas). */ + app.setFixedDeltaTime(1.0f / 60.0f); + } + + app.initApp(); + + if (headless) { + /* Headless mode never creates EditorUISystem, which + * owns the CharacterRegistry singleton; the demo scene + * has a character spawner, so provide a bare registry + * to keep spawner resolution from asserting. The + * character then falls back to an inline spawn without + * a physics capsule (fine for a smoke run). */ + static CharacterRegistry s_characterRegistry; + s_characterRegistry.setWorld(app.getWorld()); + s_characterRegistry.setSceneManager(app.getSceneManager()); + s_characterRegistry.initialize(); + } + + /* Meshes + materials referenced by the scene entities. */ + createDemoResources(); + + /* With an embedded project, EditorApp::setup() skipped the + * startup menu; the start scene defaults to the embedded + * one and can still be overridden positionally. */ + (void)sceneArgGiven; + std::cout << "[demo] starting new game with scene: " + << sceneFile << std::endl; + app.startNewGame(sceneFile); + std::cout << "[demo] controls: mouse = look, W/A/S/D = move, " + "Shift = run, E = use door, Escape = pause menu" + << std::endl; + + ExitAfterFirstFrameListener exitListener(app.getRoot()); + if (exitAfterFirstFrame && !testSwitch && !testVehicle) + app.getRoot()->addFrameListener(&exitListener); + + SceneSwitchTestListener testListener(&app); + if (testSwitch) + app.getRoot()->addFrameListener(&testListener); + + VehicleTestListener vehicleListener(&app); + if (testVehicle) + app.getRoot()->addFrameListener(&vehicleListener); + + DebugCaptureListener captureListener(&app); + if (forcePos) { + captureListener.teleportEnabled = true; + captureListener.teleportPos = forcePosVec; + captureListener.teleportYawDeg = forcePosYaw; + captureListener.teleportPitchDeg = forcePosPitch; + } + if (shotFrame > 0 && !shotFile.empty()) + captureListener.shotFrame = shotFrame; + captureListener.shotFile = shotFile; + if (forcePos || captureListener.shotFrame > 0) + app.getRoot()->addFrameListener(&captureListener); + + app.getRoot()->startRendering(); + + if (testSwitch) { + if (testListener.failed) { + std::cerr << "[test] FAIL: " + << testListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (testListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testVehicle) { + if (vehicleListener.failed) { + std::cerr << "[test] FAIL: " + << vehicleListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (vehicleListener.phase != 4) { + std::cerr << "[test] FAIL: incomplete (vehicle " + "phase " + << vehicleListener.phase << ")" << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + /* Destroy scene entities while the systems are still alive: + * CharacterSpawnerSystem registers an OnRemove observer on + * CharacterSpawnerComponent that dereferences the system, and + * the flecs world only dies after destroyEditorSystems() in + * ~EditorApp, so letting the spawner entity live that long + * would call back into a destroyed system. */ + app.clearScene(); + app.closeApp(); + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json b/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json new file mode 100644 index 0000000..4e40ef5 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_exterior.json @@ -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" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json b/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json new file mode 100644 index 0000000..3621489 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_interior.json @@ -0,0 +1,1522 @@ +{ + "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 + } + } + }, + { + "cellGrid": { + "ceilingRectName": "ceiling", + "cellHeight": 4.0, + "cellSize": 2.0, + "cells": [ + { + "flags": 147495, + "x": -1, + "y": 0, + "z": -2 + }, + { + "flags": 131107, + "x": 0, + "y": 0, + "z": -2 + }, + { + "flags": 163883, + "x": 1, + "y": 0, + "z": -2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": -1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": -1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": -1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 0 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 2 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 2 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 3 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 4 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 4 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 4 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 5 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 5 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 5 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 6 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 6 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 6 + }, + { + "flags": 81927, + "x": -1, + "y": 0, + "z": 7 + }, + { + "flags": 1048579, + "x": 0, + "y": 0, + "z": 7 + }, + { + "flags": 98315, + "x": 1, + "y": 0, + "z": 7 + }, + { + "flags": 4326403, + "x": -1, + "y": 0, + "z": 8 + }, + { + "flags": 2097155, + "x": 0, + "y": 0, + "z": 8 + }, + { + "flags": 8521731, + "x": 1, + "y": 0, + "z": 8 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 9 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 9 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 9 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 10 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 10 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 10 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 11 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 11 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 11 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 12 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 12 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 12 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 13 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 13 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 13 + }, + { + "flags": 20976643, + "x": -1, + "y": 0, + "z": 14 + }, + { + "flags": 1048835, + "x": 0, + "y": 0, + "z": 14 + }, + { + "flags": 25171971, + "x": 1, + "y": 0, + "z": 14 + } + ], + "depth": 10, + "doorActionName": "", + "doorConfigs": { + "Z:0:0:15": { + "actionName": "", + "disabled": false, + "hasOverride": true, + "keyItemId": "", + "label": "Exit door", + "lockable": false, + "lockedByDefault": false, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": false, + "sceneSwitchPath": "demo_scene_exterior.json", + "sceneSwitchTarget": "arrival_b", + "swingReversed": false + }, + "Z:0:0:8": { + "actionName": "", + "disabled": false, + "hasOverride": false, + "keyItemId": "", + "label": "Demo locked door", + "lockable": true, + "lockedByDefault": true, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": true, + "sceneSwitchPath": "", + "sceneSwitchTarget": "", + "swingReversed": false + } + }, + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorSceneSwitchPath": "", + "doorSceneSwitchTarget": "", + "doorSwingReversed": false, + "doorUseMeshMaterial": false, + "doorsEnabled": true, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "floor", + "friction": 0.5, + "furnitureCells": [], + "generationMode": "interiorOnly", + "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6", + "height": 1, + "intDoorFrameRectName": "", + "intWallRectName": "", + "intWindowFrameRectName": "", + "roofSideRectName": "", + "roofTopRectName": "", + "width": 10 + }, + "children": [ + { + "children": [], + "clearArea": { + "clearCells": true, + "clearFurniture": true, + "clearRoofs": false, + "clearRooms": false, + "maxX": 8, + "maxY": 1, + "maxZ": 20, + "minX": -8, + "minY": 0, + "minZ": -8 + }, + "id": 4294967786, + "name": { + "name": "r1" + }, + "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": 8589935095, + "name": { + "name": "r2" + }, + "room": { + "connectedRoomIds": [ + "room_1788724060923073709_509" + ], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": false, + "exits": [ + false, + true, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 8, + "minX": -1, + "minY": 0, + "minZ": -2, + "persistentId": "room_1788648326350139622_498", + "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 + } + } + }, + { + "children": [], + "id": 4294967816, + "name": { + "name": "r3" + }, + "room": { + "connectedRoomIds": [ + "room_1788648326350139622_498" + ], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": true, + "exits": [ + false, + true, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 15, + "minX": -1, + "minY": 0, + "minZ": 8, + "persistentId": "room_1788724060923073709_509", + "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": 4294967785, + "name": { + "name": "interrior" + }, + "proceduralMaterial": { + "ambient": { + "b": 0.20000000298023224, + "g": 0.20000000298023224, + "r": 0.20000000298023224 + }, + "diffuse": { + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "diffuseTextureId": "id_1788648541764281950_3", + "materialId": "id_1788648527415023537_1", + "materialName": "ProceduralMat_id_1788648527415023537_1", + "roughness": 0.5, + "shininess": 32.0, + "specular": { + "b": 0.0, + "g": 0.0, + "r": 0.0 + } + }, + "proceduralTexture": { + "colors": [ + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + } + ], + "namedRects": [ + { + "name": "ceiling", + "u1": 0.10000000149011612, + "u2": 0.20000000298023224, + "v1": 0.0, + "v2": 0.10000000149011612 + }, + { + "name": "floor", + "u1": 0.0, + "u2": 0.10000000149011612, + "v1": 0.0, + "v2": 0.10000000149011612 + } + ], + "textureId": "id_1788648541764281950_3", + "textureName": "ProceduralTex_id_1788648541764281950_3", + "textureSize": 512, + "uvMargin": 0.009999999776482582 + }, + "sceneScript": { + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "scriptPath": "" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.013780713081359863, + "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 + } + } + } + ], + "version": "1.0" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json b/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json new file mode 100644 index 0000000..e157fce --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/demo_scene_vehicletest.json @@ -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" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-sokoban/gen_heightmap.py b/src/features/editScene/demos/demo-sokoban/gen_heightmap.py new file mode 100644 index 0000000..4e04234 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/gen_heightmap.py @@ -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(" 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() diff --git a/src/features/editScene/demos/demo-sokoban/heightmap.bin b/src/features/editScene/demos/demo-sokoban/heightmap.bin new file mode 100644 index 0000000..97f73bd Binary files /dev/null and b/src/features/editScene/demos/demo-sokoban/heightmap.bin differ diff --git a/src/features/editScene/demos/demo-sokoban/imgui.ini b/src/features/editScene/demos/demo-sokoban/imgui.ini new file mode 100644 index 0000000..c0edfb3 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/imgui.ini @@ -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 + diff --git a/src/features/editScene/demos/demo-sokoban/project.h.in b/src/features/editScene/demos/demo-sokoban/project.h.in new file mode 100644 index 0000000..3bb789a --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/project.h.in @@ -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@ diff --git a/src/features/editScene/demos/demo-sokoban/project.json b/src/features/editScene/demos/demo-sokoban/project.json new file mode 100644 index 0000000..eb61314 --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/project.json @@ -0,0 +1,5 @@ +{ + "appName": "demo-sokoban", + "startScene": "demo_scene_interior.json", + "gameMode": true +} diff --git a/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake b/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake new file mode 100644 index 0000000..f0c056c --- /dev/null +++ b/src/features/editScene/demos/demo-sokoban/stage_runtime.cmake @@ -0,0 +1,66 @@ +# Stage everything demoSokoban needs into its own directory so it +# runs standalone from +# /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() diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index 35a7d5a..25082c4 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -35,6 +35,10 @@ #include #include #include +#include +#include +#include +#include #include // STL includes @@ -568,6 +572,12 @@ class Physics { JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f); std::unordered_map > 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 vehicles; + JPH::Ref 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( + 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( + 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::msSingleton = 0; diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index d1ebd65..dc4ca7f 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -12,6 +12,7 @@ #include #include #include +#include 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 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 diff --git a/src/features/editScene/systems/PhysicsSystem.cpp b/src/features/editScene/systems/PhysicsSystem.cpp index 55f529e..25c65ac 100644 --- a/src/features/editScene/systems/PhysicsSystem.cpp +++ b/src/features/editScene/systems/PhysicsSystem.cpp @@ -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()) { auto &collider = rigidBodyEntity.get_mut(); - auto &transform = rigidBodyEntity.get(); 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); } } diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index b703fb4..70aaaf0 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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()) { + json["vehicle"] = serializeVehicle(entity); + } + if (entity.has()) { 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(rb); } +nlohmann::json SceneSerializer::serializeVehicle(flecs::entity entity) +{ + auto &vehicle = entity.get(); + 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(vehicle); +} + void SceneSerializer::deserializeCollider(flecs::entity entity, const nlohmann::json &json) { diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index c059d57..9a4f6eb 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -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, diff --git a/src/features/editScene/systems/VehicleSystem.cpp b/src/features/editScene/systems/VehicleSystem.cpp new file mode 100644 index 0000000..1b52ecc --- /dev/null +++ b/src/features/editScene/systems/VehicleSystem.cpp @@ -0,0 +1,223 @@ +#include "VehicleSystem.hpp" +#include "../components/RigidBody.hpp" +#include "../components/Transform.hpp" +#include +#include +#include +#include + +VehicleSystem::VehicleSystem(flecs::world &world, JoltPhysicsWrapper *physics) + : m_world(world) + , m_physics(physics) + , m_query(world + .query()) +{ + m_removeObserver = + m_world.observer("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()) + return; + const TransformComponent &t = entity.get(); + 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); + } + }); +} diff --git a/src/features/editScene/systems/VehicleSystem.hpp b/src/features/editScene/systems/VehicleSystem.hpp new file mode 100644 index 0000000..2d45732 --- /dev/null +++ b/src/features/editScene/systems/VehicleSystem.hpp @@ -0,0 +1,63 @@ +#ifndef EDITSCENE_VEHICLESYSTEM_HPP +#define EDITSCENE_VEHICLESYSTEM_HPP +#pragma once + +#include +#include +#include +#include +#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 nodes; + std::vector 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 m_query; + flecs::entity m_removeObserver; + + std::map m_wheelVisuals; + /* Per-vehicle previous forward input for brake/reverse logic. */ + std::map m_prevForward; +}; + +#endif // EDITSCENE_VEHICLESYSTEM_HPP diff --git a/src/features/editScene/ui/VehicleEditor.cpp b/src/features/editScene/ui/VehicleEditor.cpp new file mode 100644 index 0000000..bf14888 --- /dev/null +++ b/src/features/editScene/ui/VehicleEditor.cpp @@ -0,0 +1,105 @@ +#include "VehicleEditor.hpp" +#include + +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; +} diff --git a/src/features/editScene/ui/VehicleEditor.hpp b/src/features/editScene/ui/VehicleEditor.hpp new file mode 100644 index 0000000..fefe507 --- /dev/null +++ b/src/features/editScene/ui/VehicleEditor.hpp @@ -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 { +public: + bool renderComponent(flecs::entity entity, + VehicleComponent &vehicle) override; + const char *getName() const override { return "Vehicle"; } +}; + +#endif // EDITSCENE_VEHICLEEDITOR_HPP