diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index fdd71ec..07ca8b7 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -135,6 +135,67 @@ cd demos/demo-scene-switching-extra # Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door, # Escape = pause menu (frees the cursor). +# Demo: same scene-switch-door setup as demo-scene-switching-extra, but +# the exterior scene is a real outdoor environment instead of a flat +# colored floor (demos/demo-interior-exterior-dynamics, target +# demoInteriorExteriorDynamics). demo_scene_interior.json keeps the +# interiorOnly CellGrid room (own ProceduralMaterial/ProceduralTexture, +# F6 persistent locked door + inline scene script, F1 exit scene-switch +# door Z:0:0:15 to demo_scene_exterior.json). demo_scene_exterior.json +# holds the exteriorOnly shell (entity "x1"/"w1", grid-wide +# doorSceneSwitchPath back to demo_scene_interior.json) standing on a +# STREAMING terrain entity (streamingEnabled, worldSizeUnits 10000 -> +# 5x5 pages of 2000 units, layers Ground23/Ground37; base heights from +# baseNoise OpenSimplex2 seed 55, 3 octaves, frequency 0.0003, +# amplitude 15 -> an archipelago of large islands, ~34% land, the rest +# 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 ~(4000, 4000) on an island with 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) - Y values probed from +# TerrainSystem::getHeightAt; the --test-switch exterior arrival check +# re-reads the expected Y from getHeightAt and polls while the streaming +# window loads and the grounding watchdog re-clamps, then asserts the +# grid site is above the water level (dry-island check). The "sky" +# entity (skybox + sun) sits at the content site (4000, 10, 4000): +# EditorSunSystem parents the sun/moon sphere nodes to the root node +# and orbits them around the camera (radius 200), so they line up with +# the shader sun/moon discs, but the light node stays on the sky +# entity's transform node, so the entity should sit near the content +# for sensible shadow placement. A "water" +# entity (planeSize 12000, waterSurfaceY 6.0 in both waterPlane and +# waterPhysics, buoyancy modeled on town8.json) completes the scene: +# terrain below Y 6 is ocean floor while the content island (terrain +# ~10 at the site) stays dry. The shipped heightmap +# heightmaps/4242424300000001/heightmap.bin (generated by the demo's +# gen_heightmap.py, staged via configure_file so source changes re-copy +# on the next build) is unused in streaming mode but keeps the +# non-streaming fallback working. +cd demos/demo-interior-exterior-dynamics +./demoInteriorExteriorDynamics +# ...or headless smoke run (one frame, then exit): +./demoInteriorExteriorDynamics --headless --exit-after-first-frame +# ...or headless end-to-end check of the interior -> exterior -> +# interior round trip through both scene-switch doors plus the F6 +# locked-door contract (exits non-zero on failure): +./demoInteriorExteriorDynamics --headless --test-switch +# Debug capture aids (offscreen/xvfb screenshots): teleport the player +# once it is alive (optionally aiming the TPS camera, degrees) and dump +# frame N to a file, e.g. +# xvfb-run -a -s "-screen 0 1920x1080x24" \ +# ./demoInteriorExteriorDynamics demo_scene_exterior.json \ +# --force-pos 4800 9.5 4000 270 5 --screenshot 350 /tmp/shot.png +# Swimming: the spawned character's ride height/damping come from +# buoyancyInfo in prefabs/char_2.json (buoyancy 1.05 -> ~95% submerged, +# linearDrag 0.6, angularDrag 0.2); the prefab is a runtime artifact +# staged from /src/features/editScene/prefabs. +# 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) @@ -258,7 +319,12 @@ system that should drive player locomotion animation. - Reads `GameInputState` and drives TPS/FPS camera and locomotion animation. - Sets animation states on the `locomotion` state machine: - `idle` / `walking` / `running` and the swim equivalents. + `idle` / `walking` / `running` and the swim equivalents. The swim + states are only selected when the character is actually floating + (`InWater` tag set *and* `CharacterComponent::isSupported` false); + when wading in shallow water the character stands on the bottom, so + the land states keep playing. `isSupported` is refreshed every frame + by `CharacterSystem` from `JPH::Character::IsSupported()`. - In game mode it adds `PlayerControlledComponent` to the target entity so AI systems skip it. - When `targetCharacterName` resolves to a `CharacterSpawnerComponent`, it locks @@ -286,6 +352,29 @@ bool isSpawnerLocked(flecs::entity spawner) const; Spawned characters have `EditorMarkerComponent` removed and are removed from `EditorUISystem` caches so they don't appear in editor lists. +### EditorSkyboxSystem / EditorSunSystem + +The procedural sky (`Skybox/Dynamic` material) is rendered as a fullscreen +NDC triangle (`EditorSkyboxSystem::createSkyTriangle`); the vertex shader +reconstructs the per-pixel view ray from the camera basis uniforms +(`camForward` / `camRight` / `camUp` / `tanHalfFov`). It used to be a +camera-following cube, but the cube faces behind the camera rasterised with +a degenerate (zero) interpolated position and painted horizon-glow +"circles" around the view direction. All vertex uniforms are set manually +every frame from the main camera in `updateShaderParams()`: auto constants +did not reach the vertex program on all render systems, and a manual +`Matrix4` uniform did not land in the `OGRE_UNIFORMS` block either, so only +vector types are used (the water reflection/refraction passes reuse the +main camera's basis, which is close enough for a sky). The legacy +`SkyboxComponent::size` field is still (de)serialized but unused; the +skybox editor shows it disabled. + +`EditorSunSystem` parents the sun/moon sphere nodes to the root node and +orbits them around the camera (radius 200) so they line up with the shader +sun/moon discs; the spheres hide once `sunElev` passes ~the horizon +(`-0.03` / `0.03`, roughly the sphere angular radius) so they cannot shine +through water or terrain. + ### TerrainPrefabSpawnerSystem Distance-based spawn/despawn of prefab instances on terrain diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index fb7abe1..815fd43 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -1050,3 +1050,4 @@ add_subdirectory(demos/demo-lua-scene-script) 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) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index c45d469..2ef4dae 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -542,6 +542,10 @@ void EditorApp::setup() m_world, m_sceneMgr); m_waterPlaneSystem = std::make_unique( m_world, m_sceneMgr); + if (m_uiSystem) { + m_uiSystem->setSunSystem(m_sunSystem.get()); + m_uiSystem->setSkyboxSystem(m_skyboxSystem.get()); + } // TerrainSystem — owns Ogre terrain singletons, needs camera for paging. { @@ -2264,10 +2268,32 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) /* --- Buoyancy system (before physics so impulse is integrated) --- */ if (m_buoyancySystem) { - // Update camera position for water detection area - if (m_camera) { + /* Update the camera position for the water detection + * area. Use the real camera node position, not + * EditorCamera::getPosition() - that cached value is + * only updated by editor-mode fly movement and stays + * at its initial value in game mode, which would pin + * the water detection box to the world origin. The + * broadphase query works in world space, so convert + * from render space. */ + Ogre::Camera *cam = nullptr; + if (m_sceneMgr->hasCamera("PlayerCamera")) + cam = m_sceneMgr->getCamera("PlayerCamera"); + if (!cam && m_camera) + cam = m_camera->getCamera(); + if (cam) { Ogre::Vector3 cameraPos = - m_camera->getPosition(); + cam->getDerivedPosition(); + RenderOriginSystem *ro = + RenderOriginSystem::getInstance(); + if (ro) { + JPH::DVec3 worldPos = + ro->renderToWorld(cameraPos); + cameraPos = Ogre::Vector3( + (float)worldPos.GetX(), + (float)worldPos.GetY(), + (float)worldPos.GetZ()); + } m_buoyancySystem->setCameraPosition(cameraPos); } m_buoyancySystem->update(evt.timeSinceLastFrame); @@ -2291,7 +2317,12 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) /* --- Rendering support systems --- */ if (m_sunSystem) { - m_sunSystem->update(evt.timeSinceLastFrame); + Ogre::Camera *cam = nullptr; + if (m_sceneMgr->hasCamera("PlayerCamera")) + cam = m_sceneMgr->getCamera("PlayerCamera"); + if (!cam && m_camera) + cam = m_camera->getCamera(); + m_sunSystem->update(evt.timeSinceLastFrame, cam); } if (m_skyboxSystem) { Ogre::Camera *cam = nullptr; diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index a5d2db6..4d3cb37 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -315,6 +315,10 @@ public: { return m_characterSpawnerSystem.get(); } + PlayerControllerSystem *getPlayerControllerSystem() const + { + return m_playerControllerSystem.get(); + } TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const { return m_terrainPrefabSpawnerSystem.get(); diff --git a/src/features/editScene/components/Character.hpp b/src/features/editScene/components/Character.hpp index 025aabd..9d0a989 100644 --- a/src/features/editScene/components/Character.hpp +++ b/src/features/editScene/components/Character.hpp @@ -9,9 +9,10 @@ /** * Character physics component * - * Attaches a Jolt JPH::Character (kinematic capsule) to the entity. - * The entity may also have CharacterSlotsComponent; the character - * physics lives on the same entity as the visual character. + * Attaches a Jolt JPH::Character (dynamic capsule, translation-only) + * to the entity. The entity may also have CharacterSlotsComponent; + * the character physics lives on the same entity as the visual + * character. * * Child entities can add extra collision shapes via PhysicsColliderComponent. */ @@ -50,6 +51,12 @@ struct CharacterComponent { float floorCheckDistance = 2.0f; bool useGravity = true; + /* Ground contact state from JPH::Character::IsSupported(), refreshed + * by CharacterSystem every frame. Runtime only — used to tell + * "floating/swimming" apart from "standing on the bottom in shallow + * water" (InWater alone cannot). */ + bool isSupported = false; + /* Per-character collision group. Body = subgroup 0, head = subgroup 1, * hair joints = 2+. Runtime only — regenerated when character is rebuilt. */ uint32_t collisionGroupId = 0; diff --git a/src/features/editScene/components/Skybox.hpp b/src/features/editScene/components/Skybox.hpp index 8ad1ef5..c9861ec 100644 --- a/src/features/editScene/components/Skybox.hpp +++ b/src/features/editScene/components/Skybox.hpp @@ -5,9 +5,9 @@ #include /** - * Skybox component - procedural sky rendered as a large cube - * with a fragment shader that creates dynamic day/night/sunset - * sky gradients. + * Skybox component - procedural sky rendered as a fullscreen + * triangle with a fragment shader that creates dynamic + * day/night/sunset sky gradients from the per-pixel view ray. * * Designed to work alongside SunComponent on the same entity. * If no SunComponent is present, uses default noon lighting. @@ -16,7 +16,8 @@ struct SkyboxComponent { // Enable/disable skybox bool enabled = true; - // Size of the skybox cube (default 500) + // Legacy size of the old skybox cube; kept for scene + // compatibility but unused - the sky is a fullscreen triangle now. float size = 500.0f; // Day sky colors diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/CMakeLists.txt b/src/features/editScene/demos/demo-interior-exterior-dynamics/CMakeLists.txt new file mode 100644 index 0000000..bcc90f7 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/CMakeLists.txt @@ -0,0 +1,145 @@ +# --------------------------------------------------------------------------- +# demo-interior-exterior-dynamics — scene switching demo with a CellGrid +# interior and a terrain/sky/water exterior +# --------------------------------------------------------------------------- +# Same scene-switch-door setup as demos/demo-scene-switching-extra, but +# the exterior scene replaces the flat colored floor with a real outdoor +# environment: a STREAMING terrain entity (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 — with the shipped heightmap.bin +# kept staged for non-streaming fallback), a sky entity (skybox + sun) and +# a water entity (waterPlane + waterPhysics), modeled on town8.json. The +# bounded streaming world spans [-1000, 9000] on both axes around the +# terrain entity at the origin, so all exterior content (exteriorOnly +# CellGrid shell, arrival_b, spawner s1, sky, water) is placed near the +# world center at ~(4000, 4000) on an island with 500+ units of dry land +# in every direction, with the grid floor and arrival marker resting on +# the terrain surface (heights probed from the engine) and the water level +# at Y 6 so the site (terrain ~10) stays dry while everything below is +# ocean floor. The sky entity sits at the content site because +# EditorSunSystem keeps the sun's light node on its transform node (the +# sun/moon spheres themselves orbit the camera). The interior scene is +# unchanged: an "interrior" entity +# with a CellGridComponent (a room with a floor, ceiling, interior walls +# and an exit door) that carries its own ProceduralMaterial + +# ProceduralTexture. The grid's texture rectangle names reference the +# texture's named rects ("floor" / "ceiling"), demonstrating material/UV +# pickup from a grid entity without a Lot/District/Town parent. +# +# 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-interior-exterior-dynamics +# ./demoInteriorExteriorDynamics +# 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(demoInteriorExteriorDynamics + demo_main.cpp + ${DEMO_SOURCES} +) + +target_compile_definitions(demoInteriorExteriorDynamics + PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT) + +add_dependencies(demoInteriorExteriorDynamics morph) + +# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor) +target_compile_definitions(demoInteriorExteriorDynamics PRIVATE JPH_DEBUG_RENDERER) + +target_link_libraries(demoInteriorExteriorDynamics + 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(demoInteriorExteriorDynamics 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 demoInteriorExteriorDynamics 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-interior-exterior-dynamics standalone runtime" +) diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp new file mode 100644 index 0000000..3d5fd95 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_main.cpp @@ -0,0 +1,885 @@ +#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 +#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; + } + 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; + } + 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; + } +}; + +/* + * demo-interior-exterior-dynamics: same scene-switching demo as + * demos/demo-scene-switching-extra, but the exterior scene is a real + * outdoor environment instead of a flat colored floor. 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. 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 present), 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. + */ +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 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 == "--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; + } + } + app.setHeadless(headless); + + 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) + app.getRoot()->addFrameListener(&exitListener); + + SceneSwitchTestListener testListener(&app); + if (testSwitch) + app.getRoot()->addFrameListener(&testListener); + + 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; + } + } + + /* 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-interior-exterior-dynamics/demo_scene_exterior.json b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_scene_exterior.json new file mode 100644 index 0000000..e2c1a67 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_scene_exterior.json @@ -0,0 +1,771 @@ +{ + "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.6, + "y": 9.9905, + "z": 3995.9 + }, + "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": true, + "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.0148, + "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": 495, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 4000.0, + "y": 9.9039, + "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": 496, + "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": 497, + "name": { + "name": "terrain" + }, + "terrain": { + "auxMaps": [], + "baseNoise": { + "amplitude": 15.0, + "frequency": 0.0003, + "lacunarity": 2.0, + "octaves": 3, + "persistence": 0.5, + "seed": 55 + }, + "blendMapSize": 1024, + "compositeMapDistance": 300.0, + "detailNoise": { + "amplitude": 10.0, + "enabled": false, + "frequency": 0.006, + "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.3, + "roadVisibilityDistance": 1000.0, + "sidewalkEnabled": false, + "sidewalkHeight": 0.15, + "sidewalkMeshTemplate": "", + "sidewalkThickness": 0.3, + "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": 498, + "name": { + "name": "sky" + }, + "skybox": { + "cloudiness": 0.0, + "dayBottomColor": [ + 0.6, + 0.8, + 1.0 + ], + "dayTopColor": [ + 0.2, + 0.5, + 1.0 + ], + "enabled": true, + "moonSize": 0.03, + "nightBottomColor": [ + 0.05, + 0.05, + 0.15 + ], + "nightTopColor": [ + 0.0, + 0.0, + 0.05 + ], + "size": 443.0, + "starsEnabled": false, + "sunSize": 0.05, + "sunriseColor": [ + 1.0, + 0.5, + 0.2 + ], + "sunsetColor": [ + 1.0, + 0.3, + 0.1 + ] + }, + "sun": { + "ambientDay": [ + 0.3, + 0.3, + 0.3 + ], + "ambientNight": [ + 0.05, + 0.05, + 0.15 + ], + "ambientSunrise": [ + 0.3, + 0.2, + 0.15 + ], + "ambientSunset": [ + 0.25, + 0.15, + 0.1 + ], + "castShadows": true, + "enabled": true, + "intensity": 1.79, + "moonColor": [ + 0.3, + 0.3, + 0.5 + ], + "moonSphereSize": 3.4, + "orbitTilt": 15.0, + "showMoonSphere": true, + "showSunSphere": true, + "sunColor": [ + 1.0, + 0.95, + 0.8 + ], + "sunSphereSize": 5.0, + "timeOfDay": 6.226653575897217, + "timeSpeed": 0.13 + }, + "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": 499, + "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.05, + "defaultBuoyancy": 1.0, + "defaultLinearDrag": 0.25, + "defaultSubmergedThreshold": 0.1, + "enabled": true, + "gravity": 9.81, + "waterDensity": 1000.0, + "waterSurfaceY": 6.0 + }, + "waterPlane": { + "autoUpdateFromWaterPhysics": true, + "enabled": true, + "planeSize": 12000.0, + "reflectivity": 0.38, + "renderTextureSize": 512, + "tiling": 0.012, + "waterColor": [ + 0.0, + 0.3, + 0.5, + 0.8 + ], + "waterSurfaceY": 6.0, + "waveScale": 0.031, + "waveSpeed": 0.98 + } + } + ], + "version": "1.0" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_scene_interior.json b/src/features/editScene/demos/demo-interior-exterior-dynamics/demo_scene_interior.json new file mode 100644 index 0000000..3621489 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/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-interior-exterior-dynamics/gen_heightmap.py b/src/features/editScene/demos/demo-interior-exterior-dynamics/gen_heightmap.py new file mode 100644 index 0000000..4e04234 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/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-interior-exterior-dynamics/heightmap.bin b/src/features/editScene/demos/demo-interior-exterior-dynamics/heightmap.bin new file mode 100644 index 0000000..97f73bd Binary files /dev/null and b/src/features/editScene/demos/demo-interior-exterior-dynamics/heightmap.bin differ diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/imgui.ini b/src/features/editScene/demos/demo-interior-exterior-dynamics/imgui.ini new file mode 100644 index 0000000..c0edfb3 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/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-interior-exterior-dynamics/inventory_config.json b/src/features/editScene/demos/demo-interior-exterior-dynamics/inventory_config.json new file mode 100644 index 0000000..dc54a49 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/inventory_config.json @@ -0,0 +1,4 @@ +{ + "fontPath": "Jupiteroid-Bold.ttf", + "fontSize": 16.0 +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/project.h.in b/src/features/editScene/demos/demo-interior-exterior-dynamics/project.h.in new file mode 100644 index 0000000..3bb789a --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/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-interior-exterior-dynamics/project.json b/src/features/editScene/demos/demo-interior-exterior-dynamics/project.json new file mode 100644 index 0000000..78e240b --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/project.json @@ -0,0 +1,5 @@ +{ + "appName": "demo-interior-exterior-dynamics", + "startScene": "demo_scene_interior.json", + "gameMode": true +} diff --git a/src/features/editScene/demos/demo-interior-exterior-dynamics/stage_runtime.cmake b/src/features/editScene/demos/demo-interior-exterior-dynamics/stage_runtime.cmake new file mode 100644 index 0000000..3d122b2 --- /dev/null +++ b/src/features/editScene/demos/demo-interior-exterior-dynamics/stage_runtime.cmake @@ -0,0 +1,65 @@ +# Stage everything demoInteriorExteriorDynamics needs into its own directory so it +# runs standalone from +# /src/features/editScene/demos/demo-interior-exterior-dynamics. +# +# 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) + 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/resources/materials/scripts/skybox.program b/src/features/editScene/resources/materials/scripts/skybox.program index effad36..15927f0 100644 --- a/src/features/editScene/resources/materials/scripts/skybox.program +++ b/src/features/editScene/resources/materials/scripts/skybox.program @@ -3,7 +3,15 @@ vertex_program SkyboxVP glsl glsles glslang hlsl source skybox.vert default_params { - param_named_auto worldViewProj worldviewproj_matrix + // All vertex parameters are set manually every frame by + // EditorSkyboxSystem::updateShaderParams (auto constants did + // not reach the vertex program reliably, and manual Matrix4 + // params did not land in the uniform block on all render + // systems, so only vector types are used). + param_named camForward float3 0.0 0.0 -1.0 + param_named camRight float3 1.0 0.0 0.0 + param_named camUp float3 0.0 1.0 0.0 + param_named tanHalfFov float2 1.0 1.0 } } diff --git a/src/features/editScene/resources/materials/scripts/skybox.vert b/src/features/editScene/resources/materials/scripts/skybox.vert index ce1cc5f..504f140 100644 --- a/src/features/editScene/resources/materials/scripts/skybox.vert +++ b/src/features/editScene/resources/materials/scripts/skybox.vert @@ -2,7 +2,10 @@ OGRE_NATIVE_GLSL_VERSION_DIRECTIVE #include OGRE_UNIFORMS( - uniform mat4 worldViewProj; + uniform vec3 camForward; + uniform vec3 camRight; + uniform vec3 camUp; + uniform vec2 tanHalfFov; ) OUT(vec3 vWorldPos, TEXCOORD0) @@ -11,6 +14,13 @@ MAIN_PARAMETERS IN(vec4 vertex, POSITION) MAIN_DECLARATION { - gl_Position = worldViewProj * vertex; - vWorldPos = vertex.xyz; + // The sky mesh is a fullscreen triangle in NDC (see + // EditorSkyboxSystem::createSkyTriangle). Reconstruct the view + // ray from the camera basis instead of interpolating cube vertex + // positions: cube faces behind the camera rasterised with a + // degenerate (zero) interpolated position, which painted + // horizon-glow "circles" on the sky. + vWorldPos = camForward + camRight * (vertex.x * tanHalfFov.x) + + camUp * (vertex.y * tanHalfFov.y); + gl_Position = vec4(vertex.xy, 0.999999, 1.0); } diff --git a/src/features/editScene/systems/CharacterSystem.cpp b/src/features/editScene/systems/CharacterSystem.cpp index e2767d4..4adeda5 100644 --- a/src/features/editScene/systems/CharacterSystem.cpp +++ b/src/features/editScene/systems/CharacterSystem.cpp @@ -516,6 +516,13 @@ void CharacterSystem::update(float deltaTime) } state.character->SetLinearVelocity(finalVel); + /* Publish the ground contact state so gameplay code + * (e.g. swim vs wade animation selection) can tell + * floating apart from standing on the bottom. The + * ground state itself is refreshed by PostSimulation() + * after each physics step, so this lags one frame. */ + cc.isSupported = state.character->IsSupported(); + /* Floor detection: raycast downward to find ground */ if (cc.useGravity && !cc.hasFloor) { diff --git a/src/features/editScene/systems/EditorSkyboxSystem.cpp b/src/features/editScene/systems/EditorSkyboxSystem.cpp index 8ecd8b8..3e78b57 100644 --- a/src/features/editScene/systems/EditorSkyboxSystem.cpp +++ b/src/features/editScene/systems/EditorSkyboxSystem.cpp @@ -36,8 +36,10 @@ void EditorSkyboxSystem::update(Ogre::Camera *camera) if (!skybox.sceneNode || !camera) return; - // Follow camera position - skybox.sceneNode->setPosition(camera->getDerivedPosition()); + // The sky mesh is a fullscreen NDC triangle, so there is no + // cube to keep glued to the camera; the camera argument only + // gates the shader parameter update above. + skybox.sceneNode->setVisible(true); // Get sun data from same entity or use defaults float sunElev = 1.0f; @@ -57,7 +59,7 @@ void EditorSkyboxSystem::update(Ogre::Camera *camera) } } - updateShaderParams(skybox, sunElev, sunDir); + updateShaderParams(skybox, sunElev, sunDir, camera); }); } @@ -69,15 +71,16 @@ void EditorSkyboxSystem::rebuildSkybox(flecs::entity entity, Ogre::String baseName = "Skybox_" + Ogre::StringConverter::toString(entity.id()); - // Create scene node at origin (will follow camera) + // Create scene node at origin (the mesh is a fullscreen NDC + // triangle, so the node transform does not affect rendering) skybox.sceneNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode( baseName + "Node"); - // Create manual object cube + // Create manual object fullscreen triangle skybox.manualObject = m_sceneMgr->createManualObject(baseName + "Mesh"); - createCube(skybox.manualObject, skybox.size); + createSkyTriangle(skybox.manualObject); // Get material instance Ogre::MaterialPtr material = @@ -97,8 +100,7 @@ void EditorSkyboxSystem::rebuildSkybox(flecs::entity entity, Ogre::LogManager::getSingleton().logMessage( "SkyboxSystem: Created skybox for entity " + - Ogre::StringConverter::toString(entity.id()) + - ", size=" + Ogre::StringConverter::toString(skybox.size)); + Ogre::StringConverter::toString(entity.id())); } void EditorSkyboxSystem::cleanupSkybox(SkyboxComponent &skybox) @@ -116,67 +118,34 @@ void EditorSkyboxSystem::cleanupSkybox(SkyboxComponent &skybox) } } -void EditorSkyboxSystem::createCube(Ogre::ManualObject *obj, float size) +void EditorSkyboxSystem::createSkyTriangle(Ogre::ManualObject *obj) { - float h = size * 0.5f; - + // Fullscreen triangle in clip space (NDC). The vertex shader + // reconstructs the per-pixel view ray from the inverse + // view-projection matrix, so no sky geometry ever sits behind the + // camera. (The old camera-following cube had faces behind the + // camera; those rasterised with a degenerate interpolated position + // and painted horizon-glow "circles" around the view direction.) obj->begin("Skybox/Dynamic", Ogre::RenderOperation::OT_TRIANGLE_LIST); - // Front face (+Z) - obj->position(-h, -h, h); - obj->position(h, -h, h); - obj->position(h, h, h); - obj->position(-h, h, h); - obj->triangle(0, 2, 1); - obj->triangle(0, 3, 2); - - // Back face (-Z) - obj->position(h, -h, -h); - obj->position(-h, -h, -h); - obj->position(-h, h, -h); - obj->position(h, h, -h); - obj->triangle(4, 6, 5); - obj->triangle(4, 7, 6); - - // Left face (-X) - obj->position(-h, -h, -h); - obj->position(-h, -h, h); - obj->position(-h, h, h); - obj->position(-h, h, -h); - obj->triangle(8, 10, 9); - obj->triangle(8, 11, 10); - - // Right face (+X) - obj->position(h, -h, h); - obj->position(h, -h, -h); - obj->position(h, h, -h); - obj->position(h, h, h); - obj->triangle(12, 14, 13); - obj->triangle(12, 15, 14); - - // Top face (+Y) - obj->position(-h, h, h); - obj->position(h, h, h); - obj->position(h, h, -h); - obj->position(-h, h, -h); - obj->triangle(16, 18, 17); - obj->triangle(16, 19, 18); - - // Bottom face (-Y) - obj->position(-h, -h, -h); - obj->position(h, -h, -h); - obj->position(h, -h, h); - obj->position(-h, -h, h); - obj->triangle(20, 22, 21); - obj->triangle(20, 23, 22); + obj->position(-1.0f, -1.0f, 0.0f); + obj->position(3.0f, -1.0f, 0.0f); + obj->position(-1.0f, 3.0f, 0.0f); + obj->triangle(0, 1, 2); obj->end(); + + // The vertices are NDC, so the computed AABB is a tiny box at the + // world origin and the sky would be frustum-culled from anywhere + // else; give it an infinite bounds instead. + obj->setBoundingBox(Ogre::AxisAlignedBox::BOX_INFINITE); } void EditorSkyboxSystem::updateShaderParams(SkyboxComponent &skybox, float sunElev, - const Ogre::Vector3 &sunDir) + const Ogre::Vector3 &sunDir, + Ogre::Camera *camera) { if (!skybox.manualObject) return; @@ -190,6 +159,32 @@ void EditorSkyboxSystem::updateShaderParams(SkyboxComponent &skybox, return; Ogre::Pass *pass = material->getTechnique(0)->getPass(0); + + // Vertex parameters. Set them manually rather than relying on + // param_named_auto: the auto constants did not reach the vertex + // program on all render systems (the sky rendered with a zero + // direction). Vector types only - a manual Matrix4 param did not + // land in the uniform block either. The values are for the main + // camera; the water reflection/refraction passes reuse them, which + // is close enough for a sky. + if (camera) { + Ogre::GpuProgramParametersSharedPtr vpParams = + pass->getVertexProgramParameters(); + if (vpParams) { + Ogre::Radian fovY = camera->getFOVy(); + float tanY = Ogre::Math::Tan(fovY * 0.5f); + float tanX = tanY * camera->getAspectRatio(); + vpParams->setNamedConstant( + "camForward", camera->getDerivedDirection()); + vpParams->setNamedConstant( + "camRight", camera->getDerivedRight()); + vpParams->setNamedConstant( + "camUp", camera->getDerivedUp()); + vpParams->setNamedConstant( + "tanHalfFov", Ogre::Vector2(tanX, tanY)); + } + } + Ogre::GpuProgramParametersSharedPtr fpParams = pass->getFragmentProgramParameters(); if (!fpParams) diff --git a/src/features/editScene/systems/EditorSkyboxSystem.hpp b/src/features/editScene/systems/EditorSkyboxSystem.hpp index dda42e1..49e8b02 100644 --- a/src/features/editScene/systems/EditorSkyboxSystem.hpp +++ b/src/features/editScene/systems/EditorSkyboxSystem.hpp @@ -6,8 +6,10 @@ #include /** - * Skybox system - creates and updates a procedural skybox cube - * that follows the camera and renders dynamic sky colors. + * Skybox system - creates and updates a procedural sky rendered as a + * fullscreen triangle and shaded from the per-pixel view ray, so the + * sky is correct from any camera (including the water reflection and + * refraction cameras). */ class EditorSkyboxSystem { public: @@ -17,13 +19,18 @@ public: // Update skybox position and shader parameters void update(Ogre::Camera *camera); + // Destroy the manual object and its scene node (parented to the + // root node, so it survives entity deletion unless cleaned up). + // Public so EditorUISystem::deleteEntity can release them. + void cleanupSkybox(struct SkyboxComponent &skybox); + private: void rebuildSkybox(flecs::entity entity, struct SkyboxComponent &skybox); - void cleanupSkybox(SkyboxComponent &skybox); - void createCube(Ogre::ManualObject *obj, float size); + void createSkyTriangle(Ogre::ManualObject *obj); void updateShaderParams(SkyboxComponent &skybox, - float sunElev, const Ogre::Vector3 &sunDir); + float sunElev, const Ogre::Vector3 &sunDir, + Ogre::Camera *camera); flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; diff --git a/src/features/editScene/systems/EditorSunSystem.cpp b/src/features/editScene/systems/EditorSunSystem.cpp index 17d484c..a8689cc 100644 --- a/src/features/editScene/systems/EditorSunSystem.cpp +++ b/src/features/editScene/systems/EditorSunSystem.cpp @@ -15,7 +15,7 @@ EditorSunSystem::EditorSunSystem(flecs::world &world, EditorSunSystem::~EditorSunSystem() = default; -void EditorSunSystem::update(float deltaTime) +void EditorSunSystem::update(float deltaTime, Ogre::Camera *camera) { m_query.each([&](flecs::entity entity, SunComponent &sun, TransformComponent &transform) { @@ -105,20 +105,34 @@ void EditorSunSystem::update(float deltaTime) } m_sceneMgr->setAmbientLight(ambient); - // Update sphere positions + // Update sphere positions. The sphere nodes are parented + // to the root node; orbit them around the camera (render + // space) when one is available so they always stay inside + // the camera-following skybox and line up with the shader + // sun/moon discs regardless of where the entity sits. + // Without a camera, orbit the entity transform node. float orbitRadius = 200.0f; - Ogre::Vector3 sunPos = -lightDir * orbitRadius; - Ogre::Vector3 moonPos = lightDir * orbitRadius; + Ogre::Vector3 orbitCenter = Ogre::Vector3::ZERO; + if (camera) { + orbitCenter = camera->getDerivedPosition(); + } else if (transform.node) { + orbitCenter = transform.node->_getDerivedPosition(); + } + Ogre::Vector3 sunPos = orbitCenter - lightDir * orbitRadius; + Ogre::Vector3 moonPos = orbitCenter + lightDir * orbitRadius; if (sun.sunSphereNode) { sun.sunSphereNode->setPosition(sunPos); + // Hide the sphere once it dips below the horizon + // (sphere angular radius is ~0.025 at this orbit) + // so it cannot shine through water or terrain. sun.sunSphereNode->setVisible( - sun.showSunSphere && sunElev > -0.2f); + sun.showSunSphere && sunElev > -0.03f); } if (sun.moonSphereNode) { sun.moonSphereNode->setPosition(moonPos); sun.moonSphereNode->setVisible( - sun.showMoonSphere && sunElev < 0.2f); + sun.showMoonSphere && sunElev < 0.03f); } }); } @@ -151,7 +165,8 @@ void EditorSunSystem::rebuildSun(flecs::entity entity, SunComponent &sun, lightName + "Node"); sun.lightNode->attachObject(sun.light); - // Create sun sphere + // Create sun sphere (root-node child: the spheres orbit the + // camera in update(), not the entity) if (sun.showSunSphere) { Ogre::String sunName = "SunSphere_" + Ogre::StringConverter::toString( @@ -160,7 +175,8 @@ void EditorSunSystem::rebuildSun(flecs::entity entity, SunComponent &sun, createSphere(sun.sunSphere, sun.sunSphereSize, Ogre::ColourValue(1.0f, 0.95f, 0.8f)); sun.sunSphereNode = - transform.node->createChildSceneNode(sunName + "Node"); + m_sceneMgr->getRootSceneNode()->createChildSceneNode( + sunName + "Node"); sun.sunSphereNode->attachObject(sun.sunSphere); } @@ -173,7 +189,8 @@ void EditorSunSystem::rebuildSun(flecs::entity entity, SunComponent &sun, createSphere(sun.moonSphere, sun.moonSphereSize, Ogre::ColourValue(0.8f, 0.8f, 0.9f)); sun.moonSphereNode = - transform.node->createChildSceneNode(moonName + "Node"); + m_sceneMgr->getRootSceneNode()->createChildSceneNode( + moonName + "Node"); sun.moonSphereNode->attachObject(sun.moonSphere); } diff --git a/src/features/editScene/systems/EditorSunSystem.hpp b/src/features/editScene/systems/EditorSunSystem.hpp index 54d1120..ea6d900 100644 --- a/src/features/editScene/systems/EditorSunSystem.hpp +++ b/src/features/editScene/systems/EditorSunSystem.hpp @@ -14,13 +14,21 @@ public: EditorSunSystem(flecs::world &world, Ogre::SceneManager *sceneMgr); ~EditorSunSystem(); - // Update sun position, light, and colors - void update(float deltaTime); + // Update sun position, light, and colors. When camera is not + // null the sun/moon spheres orbit the camera (render space) so + // they always stay inside the camera-following skybox and line + // up with the shader sun/moon discs; otherwise they orbit the + // entity transform node as before. + void update(float deltaTime, Ogre::Camera *camera); + + // Destroy the light, sphere meshes and their scene nodes. + // Public so EditorUISystem::deleteEntity can release them before + // destroying the entity's transform node. + void cleanupSun(struct SunComponent &sun); private: void rebuildSun(flecs::entity entity, struct SunComponent &sun, struct TransformComponent &transform); - void cleanupSun(SunComponent &sun); void createSphere(Ogre::ManualObject *obj, float radius, const Ogre::ColourValue &color); diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index cfb403b..9915bcc 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -60,6 +60,8 @@ #include "../ui/ComponentRegistration.hpp" #include "PhysicsSystem.hpp" #include "BuoyancySystem.hpp" +#include "EditorSunSystem.hpp" +#include "EditorSkyboxSystem.hpp" #include "NavMeshSystem.hpp" #include "NormalDebugSystem.hpp" #include "SceneScriptSystem.hpp" @@ -1249,6 +1251,20 @@ void EditorUISystem::deleteEntity(flecs::entity entity) } } + /* Clean up the sun/skybox runtime objects before the transform + * node: the sun's directional light would leak detached (it keeps + * illuminating the scene), and the skybox scene node is parented + * to the root node, so it would survive the entity entirely - + * both showed up as stale extra suns/skies after scene switches. */ + if (entity.has() && m_sunSystem) { + auto &sun = entity.get_mut(); + m_sunSystem->cleanupSun(sun); + } + if (entity.has() && m_skyboxSystem) { + auto &skybox = entity.get_mut(); + m_skyboxSystem->cleanupSkybox(skybox); + } + // Clean up transform node if (entity.has()) { auto &transform = entity.get_mut(); diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index 9753804..40b79c1 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -26,6 +26,8 @@ class BuoyancySystem; class NormalDebugSystem; class EditorCamera; class EditorApp; +class EditorSunSystem; +class EditorSkyboxSystem; namespace Ogre { @@ -163,6 +165,20 @@ public: m_buoyancySystem = buoyancy; } + /** + * Set sun/skybox systems so deleteEntity can release their + * runtime objects (light, spheres, skybox node) before the + * entity's transform node is destroyed. + */ + void setSunSystem(EditorSunSystem *sys) + { + m_sunSystem = sys; + } + void setSkyboxSystem(EditorSkyboxSystem *sys) + { + m_skyboxSystem = sys; + } + /** * Set normal debug system for toggle */ @@ -296,6 +312,10 @@ private: // Buoyancy system reference (for configuration) BuoyancySystem *m_buoyancySystem = nullptr; + // Sun/skybox system references (for deleteEntity cleanup) + EditorSunSystem *m_sunSystem = nullptr; + EditorSkyboxSystem *m_skyboxSystem = nullptr; + // Normal debug system reference (for toggle) NormalDebugSystem *m_normalDebugSystem = nullptr; diff --git a/src/features/editScene/systems/PlayerControllerSystem.cpp b/src/features/editScene/systems/PlayerControllerSystem.cpp index 75c03f2..1df0afb 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.cpp +++ b/src/features/editScene/systems/PlayerControllerSystem.cpp @@ -53,6 +53,15 @@ void PlayerControllerSystem::resetControllers() m_states.clear(); } +void PlayerControllerSystem::setControllerYawPitch(float yawDeg, + float pitchDeg) +{ + for (auto &pair : m_states) { + pair.second.yaw = yawDeg; + pair.second.pitch = pitchDeg; + } +} + void PlayerControllerSystem::shutdownController(ControllerState &state) { if (state.targetEntity.is_alive()) @@ -448,7 +457,16 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc, if (!ats) return; - bool inWater = state.targetEntity.has(); + /* Swim states only when actually floating: InWater fires as soon + * as the body centre dips below the surface (~chest-deep), but in + * shallow water the character still stands on the bottom (Jolt + * reports it as supported) and buoyancy cannot lift it. Playing + * the swim animation then would show an upright body sticking out + * of the water, so wading keeps the land states. */ + const CharacterComponent &cc = + state.targetEntity.get(); + bool inWater = + state.targetEntity.has() && !cc.isSupported; Ogre::String animState; if (inWater) { if (!isMoving) { diff --git a/src/features/editScene/systems/PlayerControllerSystem.hpp b/src/features/editScene/systems/PlayerControllerSystem.hpp index f18fe91..cdf90af 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.hpp +++ b/src/features/editScene/systems/PlayerControllerSystem.hpp @@ -51,6 +51,13 @@ public: */ void resetControllers(); + /** + * Override the camera yaw/pitch (degrees) of every active controller. + * Debug/testing helper used by demo screenshot capture; the next + * updateTPSCamera call re-applies it to the camera. + */ + void setControllerYawPitch(float yawDeg, float pitchDeg); + private: struct ControllerState { flecs::entity targetEntity = flecs::entity::null(); diff --git a/src/features/editScene/ui/SkyboxEditor.hpp b/src/features/editScene/ui/SkyboxEditor.hpp index 39bb86b..4105f49 100644 --- a/src/features/editScene/ui/SkyboxEditor.hpp +++ b/src/features/editScene/ui/SkyboxEditor.hpp @@ -23,11 +23,12 @@ public: component.markDirty(); } - if (ImGui::SliderFloat("Box Size", &component.size, 100.0f, 2000.0f, - "%.0f")) { - changed = true; - component.markDirty(); - } + // The sky is a fullscreen triangle now; the legacy cube size + // is loaded from old scenes but has no visual effect. + ImGui::BeginDisabled(); + ImGui::SliderFloat("Box Size (legacy)", &component.size, 100.0f, + 2000.0f, "%.0f"); + ImGui::EndDisabled(); ImGui::Separator(); ImGui::Text("Day Colors");