Sky and water are fixed for demo

This commit is contained in:
2026-09-10 22:16:33 +03:00
parent 1572066df3
commit bb65c589f4
29 changed files with 4022 additions and 98 deletions
+90 -1
View File
@@ -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 <build>/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
+1
View File
@@ -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)
+35 -4
View File
@@ -542,6 +542,10 @@ void EditorApp::setup()
m_world, m_sceneMgr);
m_waterPlaneSystem = std::make_unique<EditorWaterPlaneSystem>(
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;
+4
View File
@@ -315,6 +315,10 @@ public:
{
return m_characterSpawnerSystem.get();
}
PlayerControllerSystem *getPlayerControllerSystem() const
{
return m_playerControllerSystem.get();
}
TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const
{
return m_terrainPrefabSpawnerSystem.get();
@@ -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;
+5 -4
View File
@@ -5,9 +5,9 @@
#include <Ogre.h>
/**
* 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
@@ -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 <build>/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/<terrainId>/<heightmapFile> relative to the CWD). Generated
# in the source tree by gen_heightmap.py. The terrain runs in streaming
# mode (base heights come from baseNoise, not the heightmap), but the file
# stays staged so flipping streamingEnabled off keeps working. A
# configure_file COPYONLY copy is used on purpose: it registers a configure
# dependency on the source file, so regenerating heightmap.bin re-copies it
# into the staging directory on the next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
add_executable(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"
)
@@ -0,0 +1,885 @@
#include <iostream>
#include <cstdlib>
#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 <Ogre.h>
#include <OgreRoot.h>
#include <filesystem>
#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>()) {
TransformComponent &t =
player.get_mut<TransformComponent>();
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<EntityNameComponent>().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<DoorComponent>().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<DoorComponent>();
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<DoorComponent>();
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<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
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<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
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_<doorId>. */
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<DoorComponent>().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<DoorComponent>();
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<DoorComponent>();
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_<doorId>" (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;
}
@@ -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"
}
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
streaming mode, where base heights come from the terrain's baseNoise
(FastNoiseLite) and this file is NOT sampled it is only staged next to
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
finds it. This script documents the historical non-streaming layout and
is kept as the generator of record for that staged file.
Legacy description (non-streaming mode):
The exterior scene's terrain entity (terrainId 4242424300000001,
non-streaming, worldSize 500 per page) loads its base heights from
heightmaps/4242424300000001/heightmap.bin via
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
then res*res little-endian float32 heights.
Height profile: a flat disc at y=0 around the origin (the playable area
with the building shell and the scene-switch arrival point), sloping
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
as a lake around the centre.
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
loads a fixed 3x3 page grid, so the heightmap covers the physical world
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
the height at physical coordinate (-ws + i * 3*ws / res). Physical
space maps to visual (scene) space by a half-page shift on X and a
per-page Z mirror; this script inverts that mapping so every texel gets
the height of the *visual* position it will be rendered at, then
verifies the round trip by re-sampling the generated grid the same
bilinear way the engine does.
"""
import math
import struct
import sys
RES = 256 # must match "heightmapSize" in the scene JSON
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
EDGE_RADIUS = 160.0 # slope reaches full depth here
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
def profile(r):
"""Visual height at distance r from the origin."""
if r <= FLAT_RADIUS:
return 0.0
if r >= EDGE_RADIUS:
return DEPTH
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
t = t * t * (3.0 - 2.0 * t) # smoothstep
return DEPTH * t
def physical_to_visual(px, pz):
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
vx = px - WORLD_SIZE * 0.5
page = math.floor(pz / WORLD_SIZE)
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
return vx, vz
def visual_to_physical(vx, vz):
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
px = vx + WORLD_SIZE * 0.5
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
return px, pz
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
span = 3.0 * WORLD_SIZE
origin = -WORLD_SIZE
grid = []
for iz in range(RES):
row = []
pz = origin + iz * span / RES
for ix in range(RES):
px = origin + ix * span / RES
vx, vz = physical_to_visual(px, pz)
row.append(profile(math.hypot(vx, vz)))
grid.append(row)
with open(out_path, "wb") as f:
f.write(struct.pack("<I", RES))
for row in grid:
f.write(struct.pack("<%df" % RES, *row))
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
# (bilinear, physical coords) at a few visual positions.
def sample(vx, vz):
px, pz = visual_to_physical(vx, vz)
fx = (px - origin) / span * RES
fz = (pz - origin) / span * RES
x0 = max(0, min(int(math.floor(fx)), RES - 1))
z0 = max(0, min(int(math.floor(fz)), RES - 1))
x1 = max(0, min(x0 + 1, RES - 1))
z1 = max(0, min(z0 + 1, RES - 1))
tx = fx - int(math.floor(fx))
tz = fz - int(math.floor(fz))
h00 = grid[z0][x0]
h10 = grid[z0][x1]
h01 = grid[z1][x0]
h11 = grid[z1][x1]
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
(1 - tx) * tz * h01 + tx * tz * h11)
checks = [
("arrival (0,24)", 0.0, 24.0),
("origin (0,0)", 0.0, 0.0),
("building corner (4,30)", 4.0, 30.0),
("flat edge (55,0)", 55.0, 0.0),
("mid slope (110,0)", 110.0, 0.0),
("lake (200,0)", 200.0, 0.0),
("lake (-200,200)", -200.0, 200.0),
]
ok = True
for label, vx, vz in checks:
h = sample(vx, vz)
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
% (label, vx, vz, h))
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
if abs(sample(vx, vz)) > 1e-4:
print("ERROR: playable area not flat at", vx, vz)
ok = False
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
if sample(vx, vz) > -4.9:
print("ERROR: lake area not deep enough at", vx, vz)
ok = False
if not ok:
sys.exit(1)
print("heightmap written to %s (%dx%d), all checks passed"
% (out_path, RES, RES))
if __name__ == "__main__":
main()
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,4 @@
{
"fontPath": "Jupiteroid-Bold.ttf",
"fontSize": 16.0
}
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-interior-exterior-dynamics",
"startScene": "demo_scene_interior.json",
"gameMode": true
}
@@ -0,0 +1,65 @@
# Stage everything demoInteriorExteriorDynamics needs into its own directory so it
# runs standalone from
# <build>/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()
@@ -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
}
}
@@ -2,7 +2,10 @@ OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
#include <OgreUnifiedShader.h>
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);
}
@@ -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) {
@@ -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)
@@ -6,8 +6,10 @@
#include <Ogre.h>
/**
* 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;
@@ -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);
}
@@ -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);
@@ -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<SunComponent>() && m_sunSystem) {
auto &sun = entity.get_mut<SunComponent>();
m_sunSystem->cleanupSun(sun);
}
if (entity.has<SkyboxComponent>() && m_skyboxSystem) {
auto &skybox = entity.get_mut<SkyboxComponent>();
m_skyboxSystem->cleanupSkybox(skybox);
}
// Clean up transform node
if (entity.has<TransformComponent>()) {
auto &transform = entity.get_mut<TransformComponent>();
@@ -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;
@@ -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<InWater>();
/* 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<CharacterComponent>();
bool inWater =
state.targetEntity.has<InWater>() && !cc.isSupported;
Ogre::String animState;
if (inWater) {
if (!isMoving) {
@@ -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();
+6 -5
View File
@@ -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");