From b8af541c394974580cad1a9bc7835270e5240149 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sun, 1 Nov 2026 18:46:00 +0300 Subject: [PATCH] Phase 8 checkpoint --- src/features/editScene/AGENTS.md | 329 +- src/features/editScene/CMakeLists.txt | 4 + src/features/editScene/EditorApp.cpp | 159 +- src/features/editScene/EditorApp.hpp | 23 + .../editScene/components/Character.hpp | 16 + .../editScene/components/InteriorNpc.hpp | 12 + src/features/editScene/components/Vehicle.hpp | 21 + .../editScene/demos/demo-taxi/CMakeLists.txt | 4 + .../editScene/demos/demo-taxi/PLAN.md | 531 +++- .../editScene/demos/demo-taxi/demo_main.cpp | 2734 ++++++++++++++++- .../demos/demo-taxi/stage_runtime.cmake | 7 + .../demos/demo-taxi/startup_menu.json | 38 + .../demos/demo-taxi/town_layouts.txt | 44 +- .../editScene/layoutlib/LayoutEval.cpp | 217 +- .../editScene/layoutlib/Stairwell.cpp | 809 +++++ .../editScene/layoutlib/Stairwell.hpp | 110 + src/features/editScene/physics/physics.cpp | 15 +- .../editScene/roadlib/InteriorScene.cpp | 129 +- .../editScene/roadlib/InteriorScene.hpp | 4 + .../editScene/systems/CellGridSystem.cpp | 396 ++- .../editScene/systems/CellGridSystem.hpp | 6 + .../editScene/systems/CharacterSlotSystem.cpp | 42 +- .../editScene/systems/CharacterSystem.cpp | 130 + .../editScene/systems/CharacterSystem.hpp | 12 + .../editScene/systems/EditorSunSystem.cpp | 6 +- .../editScene/systems/InteriorNpcSystem.cpp | 184 ++ .../editScene/systems/InteriorNpcSystem.hpp | 56 + .../editScene/systems/LightSystem.cpp | 10 +- .../editScene/systems/NpcTrafficSystem.cpp | 99 + .../editScene/systems/NpcTrafficSystem.hpp | 23 + .../editScene/systems/PhysicsSystem.cpp | 37 +- .../editScene/systems/PhysicsSystem.hpp | 10 + src/features/editScene/systems/RoadSystem.cpp | 27 + .../editScene/systems/RoomLayoutSystem.cpp | 15 +- .../editScene/systems/TerrainTests.cpp | 223 +- .../editScene/systems/TownGenSystem.cpp | 71 +- .../editScene/systems/TownGenSystem.hpp | 7 + .../systems/VehicleControllerSystem.cpp | 3 + .../editScene/systems/VehicleStateStore.cpp | 251 ++ .../editScene/systems/VehicleStateStore.hpp | 90 + .../tests/road_geometry_overlap_test.cpp | 300 +- 41 files changed, 6915 insertions(+), 289 deletions(-) create mode 100644 src/features/editScene/components/InteriorNpc.hpp create mode 100644 src/features/editScene/demos/demo-taxi/startup_menu.json create mode 100644 src/features/editScene/layoutlib/Stairwell.cpp create mode 100644 src/features/editScene/layoutlib/Stairwell.hpp create mode 100644 src/features/editScene/systems/InteriorNpcSystem.cpp create mode 100644 src/features/editScene/systems/InteriorNpcSystem.hpp create mode 100644 src/features/editScene/systems/VehicleStateStore.cpp create mode 100644 src/features/editScene/systems/VehicleStateStore.hpp diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index f3de43f..50419df 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -259,7 +259,14 @@ xvfb-run -a ./demoSokoban demo_scene_exterior.json \ # while driving, R/F = raise/lower the fork while driving, Escape = # pause menu (frees the cursor). -# Demo: taxi driver demo (demos/demo-taxi, target demoTaxi). Starts as +# Demo: taxi driver demo (demos/demo-taxi, target demoTaxi). Interactive +# runs show the demo-owned startup menu (NEW GAME -> the project start +# scene, demo_scene_interior.json); --headless, --test-*, benchmark/capture/debug +# flags and a positional scene start directly (test-safe). +# EditorApp::startNewGame copies the scene path before clearScene(): +# the startup menu passes StartupMenuComponent::newGameScene, and the +# component is destroyed by that teardown (dangling-reference regression +# covered by --test-menu-new-game). Starts as # an exact copy of demo-interior-exterior-dynamics (same # interior/exterior scenes, streaming terrain, scene-switch doors, # --test-switch contract); the taxi game (procedural town generation, @@ -1345,6 +1352,118 @@ their assets are still validated by `--test-ages`. See `assets/blender/characters/AGES-PLAN.md` for the full conversion/hand-off contract. +Optional detailed-nude parts live in `assets/blender/characters/nude-.blend` +(one file per age/sex, up to `top`/`bottom`/`feet`). `CharacterSlotSystem` +prefers a layer-0 entry tagged `detailed-nude` whenever no layer-1/2 garment +resolves for a slot (outfit level 0, or empty/missing garment selections); +slots without one fall back to the composition base. `init-nude-` seeds +a file from the existing base parts; the build appends it after the clothing +consolidation. See `assets/blender/characters/CHARACTER_MODELING.md` §3. + +### InteriorNpcSystem (demo-taxi phase 8.2 follow-up) + +`systems/InteriorNpcSystem.{hpp,cpp}` (EditorApp-owned, +`getInteriorNpcSystem()`) fills generated interiors with the population +NPCs whose schedule state is inside the building. The exterior +`NpcDetailSystem` deliberately never spawns indoor-location NPCs, so +interiors used to be empty. + +- Trigger: `EditorApp::performSceneSwitch` after loading an in-memory + interior document (`populateInteriorNpcs()`), and again at the end of + a deferred load-game re-entry (after `applyLoadedSaveState` destroys + every CharacterIdentity entity). +- Eligibility: for each population id, `NpcMotionSystem::motionInfoOf` + must be `AtLocation`; resolve the location in the NPC's area location + set and require `address == gridUid` minus `town-` and an indoor kind + (`loc->indoor || TownLocations::isIndoorKind`). At most 12 per + building (v1). +- Placement: room centers from the interior document's RoomComponents + (stairwell rooms excluded), deterministic 3x3 spread for roommates. + Each NPC is spawned through `CharacterRegistry::spawnCharacter` (the + record's exterior position is NOT changed), gets a `CharacterComponent` + from the record's age/sex metrics, an idle `AnimationTreeComponent` + (tree from `NpcDetailSystem::animationTreeName`), and the + `InteriorNpcTag`. +- Lifetime/saves: they are runtime scene entities, destroyed with the + interior. `saveGame` skips tagged entities in the registry-position + sync, the runtime-entity section and the character-runtime section; + the schedule state is the source of truth for the next visit. The + exterior NPC detail/taxi/traffic/avoidance tiers are skipped while a + generated interior is active (their coordinates belong to the + exterior world). +- Coverage: `--test-town-interior` sets the clock to noon, requires a + building with at least one scheduled indoor NPC, and asserts + `InteriorNpcSystem::spawnedCount() == eligibleCount()` plus the tagged + entity count; the debug hold (`DEMO_TOWN_INTERIOR_HOLD=1`) places the + player in front of the first spawned NPC for a screenshot. + +### Physics body lifetime (ghost colliders) + +A `RigidBodyComponent`'s Jolt body is destroyed by an +`OnRemove` observer in `EditorPhysicsSystem` +(registered in `initialize()`), so component removal AND entity +destruction (including `entity.destruct()` paths such as +`CellGridSystem::destroyPhysicsColliders`) always free the body. +Do not rely on `EditorUISystem::deleteEntity()` alone: direct destructs +bypass it, and any missed removal leaks a static body that later +interferes with other scenes (generated interiors are all built at the +world origin, so a leaked interior body becomes invisible furniture in +the next one). `destroyPhysicsColliders()` also removes the generated +grid's body explicitly before destructing its physics parent. + +Regression coverage: `--test-town-interior` re-enters the same building +twice and asserts an identical static collider census near the origin +(bodies + compound sub-shapes). `DEMO_INTERIOR_VISITS=N` changes the +visit count. + +### Character movement, step-up and falling (phase 8.4) + +- `CharacterSystem` has a controller-level step-up: a grounded character + whose horizontal move is BLOCKED (achieved progress < 35% of the + requested velocity) probes ankle height, above `maxStepHeight`, and + the ledge top; static geometry only (moving bodies are never stepped + onto), then lifts capsule + scene node (root-motion characters write + the node). `CharacterComponent::maxStepHeight` (default 0.4 m, 0 + disables). Stairs are untouched: their steps do not block the capsule, + so the assist does not fire on them. +- Vertical dynamics: `CharacterComponent::gravityScale` (default 1.35) + is applied as the body's gravity factor; `maxFallSpeed` (25 m/s) caps + the fall and the character body uses `EMotionQuality::LinearCast` + (CCD) so fast falls do not tunnel through thin streamed terrain + colliders. +- `JoltPhysicsWrapper` calls `Character::PostSimulation(0.2)` (the + capsule origin is ~0.10 m above the floor contact; 0.1 left + `IsSupported()` false while standing). +- Physics time scaling: `EditorPhysicsSystem` clamps the frame delta to + 0.25 s (was 0.1, which made everything run in slow motion below + 10 FPS); the wrapper runs at most 20 fixed steps per update and drops + the rest of a huge backlog. + +### Road material after scene switches (phase 8.4) + +`ProceduralMaterialSystem` wipes the road material's passes (and the +`RoadAtlasTexture` unit) whenever it restyles the material - which +happens after the exterior scene is recreated on an interior exit. +`RoadSystem::syncRoadAtlasTextureUnit` re-attaches the unit and now also +rebuilds the RTSS shader-based techniques when it does: a unit added +after the shader was generated is not sampled and the road renders flat +grey. Regression: `DEMO_TOWN_POST_EXIT_SHOT=1` writes a post-exit +screenshot; `--test-fall`/`--test-town-interior` cover the rest. + +### Interior lighting (phase 8.2 follow-up 3) + +- Ogre derives a directional light's direction as -Z of the light + node's orientation; `LightSystem` and `EditorSunSystem` both used a + (0,-1,0) base until 2026-11-01, pointing every authored directional + light (and the sun) somewhere other than the configured direction. + Both rotate from (0,0,-1) now — if an old scene's lights look + different, this is why (the old values were wrong). +- Generated interiors declare `interior.ambient` in the document + (default 0.5): enclosed rooms get no sky ambient, so without it every + surface facing away from the two interior directionals renders pitch + black. `EditorApp::performSceneSwitch` saves the scene ambient on + interior entry and restores it on exit. + ### NpcTaxiSystem (demo-taxi phase 4.7.7) `NpcTaxiSystem` (EditorApp-owned singleton `getInstance()`, game mode only, @@ -3075,16 +3194,36 @@ prefab/DSL/Lua): `exteriorOnlyMode()`; serialized + Lua-bound; editor combo in the Cell Grid editor) selects which parts `buildCellGrid()` generates: -- `"interiorOnly"` skips the exterior shell: external wall planes - (`buildWalls`), external window panels (`buildWindows`), external window - frame placement, external corners (`buildCorners`) and roofs - (`buildRoofs`). Floors, ceilings, internal walls/frames, furniture, the - exit doorway wall panels (`extDoorsTb`), external door frames and all - door entities stay — interior-only scenes are paired with exterior-only - scenes and transition through scene-switch doors. Boundary windows - (cells carrying both the internal and the matching external window - flag) get a glass pane at the internal wall plane, hiding the void - outside (see below). +- `"interiorOnly"` builds a COMPLETE standalone room set (phase 8.2 + follow-up): the outward shell planes, external window panels and + external window frame placement, external corners (`buildCorners`) and + roofs (`buildRoofs`) are skipped, but the same wall/window/door flags + are mapped onto INWARD-facing geometry at the internal wall offset + (`buildWalls` / `buildWindows` / the inward door surrounds in + `buildDoors`), and every boundary external window gets an inward frame + + glass pane at the inner plane (`buildGlass`). The mapped perimeter + planes/frames/surrounds span the FULL cell width, so adjacent cells + meet exactly (the inset internal-wall geometry would leave 0.2 m gaps + at cell boundaries). Every cell (except stair cells) gets a closed + ceiling slab, including cells with no storey above (annexes/cut + footprints); a dim up-pointing `interior-light-ceiling` lights the + undersides. Floors, internal walls/frames, furniture, the exit + doorway wall panels (`extDoorsTb`), external door frames and all door + entities stay. Interior-only scenes transition to their exterior + through scene-switch doors; generated interiors (town buildings) are + built this way and no longer need a paired shell. Pure + room-to-room windows get no glass. + + Perimeter WINDOWS get the same surround the exterior builds + (full-height side pieces + sill/lintel around the opening), mirrored + to the internal wall plane — do NOT map external window flags onto + the internal window frame pieces, those are trim only and leave the + rest of the wall open (2026-11-01). + + Caution: `buildCellGrid` must create each part mesh exactly once — + the internal-window mesh was briefly emitted twice, so every rebuild + logged `Mesh ... already exists` and kept stale/partial window + geometry (removed 2026-11-01). - `"exteriorOnly"` keeps only the shell (external walls, external door/window panels, roofs, corners, external frames, exit door entities) and adds a glass pane per external window opening. @@ -3092,8 +3231,9 @@ editor) selects which parts `buildCellGrid()` generates: thin box (2 cm) per window opening into `meshData.glassMesh`, which gets a collider in the static shell (no climbing through windows). exteriorOnly panes sit at the external wall plane; interiorOnly panes - only cover boundary windows and sit at the internal wall plane; pure - interior room-to-room windows get no glass. The pane uses + cover boundary windows (external `Window*` flags, matching the inward + frames) and sit at the internal wall plane; pure interior + room-to-room windows get no glass. The pane uses `glassMaterialName` when set, otherwise a built-in per-grid `CellGridGlass_` material created/updated by `getGlassMaterialName()` — **opaque on purpose** (no alpha blend, @@ -3127,9 +3267,9 @@ hosts a staircase from its own level up to the next. The generator: rim, so a column stacks boarding/step-off on the same side). Every step is a solid box from its flight's slab bottom to the tread top (the `TownGenSystem::createStairs` construction); `cellHeight / - ceil(cellHeight / 0.18)` rise (0.174 m at 4 m floors, 23 steps; - the 0.18 cap is EMPIRICAL - 0.25 m risers block the Jolt character - capsule at the first tread), `STAIR_TREAD` 0.30 m (shrinks with a + ceil(cellHeight / 0.14)` rise (0.138 m at 4 m floors, 29 steps; + 0.14 is the walk-speed envelope, 0.25 m risers block the Jolt + capsule at the first tread), `STAIR_TREAD` 0.25 m (shrinks with a warning on too-short runs), flight width min(1.8 m, cell width - 2 x ring), 0.4 m gap between dogleg flights. The flights go into `MeshData::stairMesh` with the regular @@ -3148,32 +3288,70 @@ hosts a staircase from its own level up to the next. The generator: `uint64` per-cell `flags` (no schema change), `stairRectName` is a normal optional rect name (old scenes default to empty). Grids without stair flags generate exactly the old geometry. -- Authoring (phase 7.3): a `RoomComponent` with +- Authoring (phase 7.3 / 8.3): a `RoomComponent` with `roomType == "stairwell"` flags its cells (`RoomLayoutSystem:: - processRoom`, idempotent for plan-derived rooms); the layout DSL's - `(room ... :type stairwell)` sets `PlanFlags::StairWell` in BOTH - rooms-first and filled-shell modes; the Cell Grid editor has a - "Stairs" texture-rect combo and the per-cell flag list shows - "Stairwell"; Lua exposes `stairRectName` on the `CellGrid` - component. A stairwell room on the top floor has no floor above - - the generator warns once and ignores those flags. -- Automatic stairwells (phase 7.3a): `TownGenSystem:: - fillBuildingCells` calls `autoPlaceStairwell()` for plans with more - than one floor. An explicit plan stair cell wins; otherwise a - deterministic straight run is carved along the longer usable axis - (4 cells first, 3 as a fallback) on the first row/column that is - free of door cells and has a boarding cell on the ground floor and - a step-off cell on the top floor. Interior walls + internal - windows on the run cells are opened (the run may cross room - boundaries), as are the facing walls at the boarding and step-off - cells. When no run fits, one `TownGen: warning - no stairwell fits - building ...` line is logged and the building stays as before. - This covers the built-in parametric layouts, `town_layouts.txt` - layouts and inline sources without editing layout text; exterior- - only shells are unchanged (stairs are interior geometry). The DSL - help window documents `:type stairwell` + the automatic stairwell, - and `LayoutLibrary::examples()` ships "Two storeys, automatic - stairwell" and an explicit-stairwell-room recipe. + processRoom`) ONLY when the room owns its geometry (any of + `createFloor/createCeiling/createInteriorWalls/createWindows` set). + Plan-derived rooms (all four off) already carry the exact flight + flags from the layout plan - the room rectangle also covers the + landings and the side walkway, which must not be opened. The layout + DSL's `(room ... :type stairwell)` flags only the derived flight + band (see the stacked stairwell contract below) in both rooms-first + and filled-shell modes; tiny rooms keep the legacy whole-room + dogleg. The Cell Grid editor has a "Stairs" texture-rect combo and + the per-cell flag list shows "Stairwell"; Lua exposes + `stairRectName` on the `CellGrid` component. A stairwell room on + the top floor has no floor above - the generator warns once and + ignores those flags. +- Stacked stairwell rooms (phase 8.3, `layoutlib/Stairwell.{hpp,cpp}`): + every multi-floor plan carries a stairwell ROOM with the SAME XZ + rectangle on every floor. Inside it the flight occupies ONE + cell-wide band on the low cross axis; one landing cell is kept at + each end of the long axis and a walkway band beside the flight, so + the top step always has floor to step onto and upper floors can walk + from the arrival landing to the next flight (3+ storey buildings). + `PlanFlags::StairWell` marks only the flight cells, on every flight + floor (all but the top floor); the top-floor room instance is a + normal room with holes in the floor over the flight below and a + regular interior door into the neighbouring room. + `LayoutEval::evaluate()` calls `synthesizeStairwell()` at the end: + it inserts the room when the layout authored none (deterministic + rect search staying inside the intersection of all floors' floor + area, avoiding exterior door cells, preferring a single room and a + building edge), or completes existing stairwell rooms on missing + floors / mismatched rects. It walls the rectangle, flags the run + and adds one interior door per floor on a landing/walkway cell + (never beside the flight). `validateStairwells()` is the layout + checker: one stairwell room per floor, identical rects, run flagged + on every flight floor, at least one interior door per floor; the + road probe asserts it for the default template, the layout examples + and the shipped town layouts (tiny footprints and legacy compact + stair rooms may fall back to the carve, warning only). +- `verbIDoor`/`connect` door placement: a door into a stairwell room + is moved to a landing end within the shared overlap when the midpoint + would open beside the flight - the boarding landing on the ground + floor, the step-off landing on upper floors (error if neither fits). +- Shipped layouts use FULL-WIDTH stair strips (1-2 cells deep at the + back) so shell clamping on tight lots cannot shrink the room below a + landing-plus-run length; `road_geometry_overlap_test` re-evaluates + them at avail 35/14/10 m and asserts `validateStairwells()`. +- Exterior-stair fallback: when a multi-floor plan cannot host the + stacked room (tiny shell) or an authored stair room is too short, + `addExteriorStairAccess()` gives every upper floor a motel-style + gallery door (existing exterior door reused, else a boundary + window/wall converted) and the `ExteriorStair` flag; + `validateVerticalAccess()` accepts stacked-or-exterior per upper + floor. `TownGenSystem::autoPlaceStairwell()` skips carving when + `ExteriorStair` exists, and too-short authored stair rooms are + demoted to normal rooms (the whole-room flight topped out against the + room wall). No shipped layout needs this fallback; the legacy carve + only remains for degenerate shells with no usable facade. +- `TownGenSystem::autoPlaceStairwell()` (phase 7.3a) remains only as a + fallback for grids that do not come from a plan with a stairwell + room (hand-authored CellGrids, footprints too small for the stacked + room): a straight run is carved along the longer usable axis, + opening the crossed room walls plus the boarding/step-off walls. + Exterior-only shells are unchanged (stairs are interior geometry). - Climbability envelope: the Jolt character only mounts a 0.174 m riser when the approach is fast enough and the tread is not too short. Measured: 1.4 m/s (NPC walk) and 1.5 m/s climb 0.30 m and @@ -3189,7 +3367,7 @@ hosts a staircase from its own level up to the next. The generator: style outside stair: a 1.5 m walkway landing at the upper floor flush with the door, a 0.9 m outer rail (wall buffer + collider), and a straight solid flight descending along the facade to the grid - base level (0.18 cap, tread 0.30, 1.4 m deep against the wall), + base level (0.14 cap, tread 0.25, 1.4 m deep against the wall), using the stair mesh + collider. The facade side comes from the cell's door/window/wall bits (exactly one door side required); the flight direction is the along-facade side with room for the run @@ -3200,13 +3378,14 @@ hosts a staircase from its own level up to the next. The generator: bottoms out at the grid base (the terrain foundation must meet it); the gallery door is a normal door (closed leaf blocks the capsule, the navmesh treats doors as passable per F7). -- Grand / lobby stairs (phase 7.5): the layout follows a stairwell - block's aspect - compact blocks build the two-flight dogleg, - elongated blocks (aspect >= 1.5, run >= 3 cells) build ONE wide - straight flight spanning the block's short side (width = +- Flight shapes: the stacked-room contract always gives `buildStairs` + a SINGLE-ROW run (one 1.7 m flight + walkway), but the builder still + handles authored grids: compact flagged blocks build the two-flight + dogleg, elongated blocks (aspect >= 1.5, run >= 3 cells) build ONE + wide straight flight spanning the block's short side (width = shortCells * cellSize - 0.30); single-row runs keep the 1.8 m - comfort cap, so existing layouts are unchanged. Wide flights reuse - the same step contract. Curved/branching flights and railings + comfort cap. Wide flights reuse the same step contract. + Curved/branching flights and railings inside stairwell rooms are explicitly out of scope (the shaft walls enclose the flights; an open grand stair ending mid-room would need a railing system). @@ -3322,6 +3501,19 @@ path of the form `generated:` is resolved by `CellGridDoorConfig`/`DoorBuildParams`/`DoorComponent` (`sceneSwitchHasPose`/`sceneSwitchPos`/`sceneSwitchYaw`, serialized + Lua-bound), which `DoorSystem` passes into `SceneSwitchOptions`. +- Interior completeness (phase 8.2 follow-up): `TownGenSystem:: + createBuilding` registers `planFromGrid()` (the final grid flags, + including `autoPlaceStairwell`'s stair cells and opened walls, not + the untouched layout plan). `InteriorScene::build` emits one room + metadata child per plan room instance (same `persistentId` + `townroom-
--`, furniture selection + tags/fallbacks, `:compose` rules and FNV seed as + `TownGenSystem::createPlanRooms`), so the shared `RoomLayoutSystem` + furnishes the interior deterministically on load. The interior grid + is built in `interiorOnly` mode (see the Generation mode section: + inward perimeter walls/windows, intermediate ceilings, stairs). + Cache invalidation follows the plan hash, which includes the + stairwell snapshot. #### Door identity & per-door configuration (F0) @@ -3775,7 +3967,46 @@ makes vehicles drivable by the player: - **Teardown**: `resetDriving()` (no raycasts) runs from `EditorApp::destroySceneEntities` before `resetControllers()`, and the system is destroyed before `PlayerControllerSystem`/`CharacterSystem`. - Driving state is runtime-only — it does not survive save/load. + Driving state is runtime-only — it does not survive save/load; the + CAR POSE does (see below). + +### VehicleStateStore + carjack ownership (demo-taxi phase 8.2 follow-up) + +Scene switches destroy every scene entity and a save reload re-reads the +base scene file, so vehicles would snap back to their authored poses. +`systems/VehicleStateStore.{hpp,cpp}` (session singleton) fixes that: + +- `VehicleControllerSystem::enterVehicle` tags the vehicle + `PlayerTouchedVehicleTag` (runtime tag, never serialized into scene + files). +- `EditorApp::performSceneSwitch` calls `captureVehicleStates()` before + the teardown and `applyVehicleStates(scenePath)` after a FILE scene + load (in-memory generated interiors have no vehicles). `saveGame` + captures (unless inside a generated interior, where the exterior + snapshot taken on entry is authoritative) and writes + `vehicleState`; `loadGame` restores it before loading the base scene + and applies it after; `startNewGame` clears it. +- A record is `{name, scene, world pose, ownerId, runtime, entity}`. + Scenes key the records, so a store entry only applies to the scene it + was captured in. Authored cars are matched by `EntityNameComponent` + and teleported (node + physics body when created, velocities zeroed, + handbrake on); runtime cars (carjacked NPC cars) carry their full + serialized entity and are re-created through + `SceneSerializer::deserializeEntityComponents` after prefab + resolution (the `PrefabInstanceComponent` is marked instantiated so a + later `resolveInstances()` cannot double-instantiate it). +- Carjacked NPC traffic cars (`NpcTrafficSystem::update`, the + `PlayerControlledComponent` branch): the car is renamed + `stolen___`, tagged `PlayerTouchedVehicleTag`, + given `VehicleOwnershipComponent{ownerId = driverRegistryId}` and + removed from the fleet — the owner walks for the rest of the theft + day. `m_stolenCars` (driver -> {area, day}) is serialized as + `stolenVehicles`; every update tries `seedArea()` for expired + entries and drops the marker once the fleet has the owner's new car, + so the owner gets a fresh car on the NEXT in-game day while the + abandoned one stays where the player left it. `deserializeStolenCars` + clamps the theft day to the live clock day (a fresh process starts at + day 0). No theft consequences yet (police mechanic later). ### SceneScriptComponent & SceneScriptSystem diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 924b705..81d8ce3 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -51,6 +51,8 @@ set(EDITSCENE_SOURCES systems/CellGridSystem.cpp systems/DoorSystem.cpp systems/GeneratedInteriorService.cpp + systems/VehicleStateStore.cpp + systems/InteriorNpcSystem.cpp systems/DoorBuilder.cpp systems/StandaloneDoorSystem.cpp components/StandaloneDoorModule.cpp @@ -994,6 +996,8 @@ add_library(LayoutLib STATIC layoutlib/LayoutLang.hpp layoutlib/LayoutEval.cpp layoutlib/LayoutEval.hpp + layoutlib/Stairwell.cpp + layoutlib/Stairwell.hpp ) target_include_directories(LayoutLib PUBLIC diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index f4846f4..70c841c 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -85,6 +85,7 @@ #include "systems/SaveLoadDialog.hpp" #include "camera/EditorCamera.hpp" #include "components/EntityName.hpp" +#include "components/InteriorNpc.hpp" #include "components/Transform.hpp" #include "components/Renderable.hpp" #include "components/EditorMarker.hpp" @@ -136,6 +137,8 @@ #include "systems/ActuatorSystem.hpp" #include "systems/DoorSystem.hpp" #include "systems/GeneratedInteriorService.hpp" +#include "systems/VehicleStateStore.hpp" +#include "systems/InteriorNpcSystem.hpp" #include "systems/StandaloneDoorSystem.hpp" #include "systems/EventHandlerSystem.hpp" #include "systems/EventBus.hpp" @@ -872,6 +875,8 @@ void EditorApp::setup() m_generatedInteriorService = std::make_unique(m_world, m_sceneMgr); + m_interiorNpcSystem = + std::make_unique(m_world, this); // Standalone doors (F2, same DoorBuilder as CellGrid doors) m_standaloneDoorSystem = @@ -1359,21 +1364,32 @@ void EditorApp::clearScene() void EditorApp::startNewGame(const Ogre::String &scenePath) { + /* The caller may pass storage owned by an entity that clearScene() + * destroys — the startup menu calls this with + * StartupMenuComponent::newGameScene, and the menu entity dies in + * the teardown. Keep a copy for the whole load. */ + const Ogre::String path = scenePath; + VehicleStateStore::getInstance().clear(); + if (m_interiorNpcSystem) + m_interiorNpcSystem->clear(); + if (m_npcTrafficSystem) + m_npcTrafficSystem->deserializeStolenCars( + nlohmann::json::array()); clearScene(); /* F9: a new game starts from system defaults, not from the * cross-session cache. */ GlobalStateStore::getInstance().clearToDefaults(); SceneSerializer serializer(m_world, m_sceneMgr); - if (serializer.loadFromFile(scenePath, m_uiSystem.get())) { + if (serializer.loadFromFile(path, m_uiSystem.get())) { PrefabSystem prefabSys(m_world, m_sceneMgr); prefabSys.resolveInstances(); SceneScriptSystem::loadPendingScripts(m_world); - SceneScriptSystem::sendSceneLoaded(scenePath); + SceneScriptSystem::sendSceneLoaded(path); setGamePlayState(GamePlayState::Playing); - m_currentBaseScene = scenePath; + m_currentBaseScene = path; m_playTime = 0.0f; Ogre::LogManager::getSingleton().logMessage( - "Game started: loaded scene " + scenePath); + "Game started: loaded scene " + path); setupPlayerCharacter(); @@ -1736,6 +1752,45 @@ static void scanSceneEntityJson(const nlohmann::json &entityJson, } } +void EditorApp::populateInteriorNpcs() +{ + if (!m_interiorNpcSystem) + return; + flecs::entity interiorGrid; + m_world.each([&](flecs::entity e, CellGridComponent &g) { + if (interiorGrid.is_alive()) + return; + if (g.generationMode == "interiorOnly" && + g.gridUid.rfind("town-", 0) == 0) + interiorGrid = e; + }); + m_interiorNpcSystem->populate(interiorGrid); +} + +void EditorApp::captureVehicleStates() +{ + if (m_gameMode != GameMode::Game) + return; + VehicleStateStore::getInstance().capture( + m_world, m_sceneMgr, m_currentBaseScene, + m_renderOriginSystem.get()); +} + +void EditorApp::applyVehicleStates(const Ogre::String &scenePath) +{ + if (m_gameMode != GameMode::Game || scenePath.empty()) + return; + const int applied = VehicleStateStore::getInstance().apply( + m_world, m_sceneMgr, scenePath, m_renderOriginSystem.get(), + m_physicsSystem ? m_physicsSystem->getPhysicsWrapper() : + nullptr); + if (applied > 0) + Ogre::LogManager::getSingleton().logMessage( + "EditorApp: restored " + + std::to_string(applied) + + " player-touched vehicle(s) for " + scenePath); +} + bool EditorApp::performSceneSwitch(const Ogre::String &scenePath, const SceneSwitchOptions &opts) { @@ -1870,6 +1925,14 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath, } } + /* Phase 8.2 follow-up: snapshot player-touched vehicles before the + * teardown so a later load of this scene restores their poses + * (authored cars and carjacked runtime cars alike). While leaving + * a generated interior there is nothing to capture: its exterior + * snapshot was taken on entry. */ + if (m_activeGeneratedInterior.empty()) + captureVehicleStates(); + destroySceneEntities(); SceneSerializer serializer(m_world, m_sceneMgr); @@ -1894,6 +1957,10 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath, } PrefabSystem prefabSys(m_world, m_sceneMgr); prefabSys.resolveInstances(); + /* Phase 8.2 follow-up: a file scene (the exterior) restores the + * player-touched cars; an in-memory interior has none. */ + if (opts.sceneDocument.is_null()) + applyVehicleStates(scenePath); SceneScriptSystem::loadPendingScripts(m_world); SceneScriptSystem::sendSceneLoaded(scenePath); /* Phase 8.2: an in-memory document is a generated interior — the @@ -1902,16 +1969,41 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath, if (!opts.sceneDocument.is_null()) { m_activeGeneratedInterior = scenePath; m_activeGeneratedSourceScene = m_currentBaseScene; + /* Interiors get no sky ambient; the document's declared + * ambient replaces the scene one for the stay and is + * restored on the way out. */ + const float amb = opts.sceneDocument + .value("interior", nlohmann::json()) + .value("ambient", 0.5f); + if (!m_interiorSavedAmbientValid) { + m_interiorSavedAmbient = + m_sceneMgr->getAmbientLight(); + m_interiorSavedAmbientValid = true; + } + m_sceneMgr->setAmbientLight( + Ogre::ColourValue(amb, amb, amb)); } else { m_activeGeneratedInterior.clear(); m_activeGeneratedSourceScene.clear(); m_currentBaseScene = scenePath; + if (m_interiorSavedAmbientValid) { + m_sceneMgr->setAmbientLight(m_interiorSavedAmbient); + m_interiorSavedAmbientValid = false; + } } /* m_playTime keeps accumulating across the switch (same session). */ setGamePlayState(GamePlayState::Playing); setupPlayerCharacter(); + /* Phase 8.2 follow-up: the exterior detail tier skips indoor NPCs + * (home/work/leisure); a generated interior populates them here + * from the same schedule state. A file scene has no interior. */ + if (!opts.sceneDocument.is_null()) + populateInteriorNpcs(); + else if (m_interiorNpcSystem) + m_interiorNpcSystem->clear(); + /* Camera handoff: a scene camera takes over the viewport only * when no player controller is active after the switch (the * PlayerControllerSystem drives the editor camera otherwise). @@ -2031,6 +2123,11 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath, std::move(m_applySaveStateAfterSwitch); m_applySaveStateAfterSwitch = nlohmann::json(); applyLoadedSaveState(saved, m_pendingSaveSlot); + /* applyLoadedSaveState destroys every spawned character and + * respawns the save's persistent set; the interior's + * scheduled NPCs must be materialized again. */ + if (!m_activeGeneratedInterior.empty()) + populateInteriorNpcs(); } EventBus::getInstance().send("scene_ready"); @@ -2056,6 +2153,8 @@ void EditorApp::saveGame(const std::string &slotPath, auto ®istry = CharacterRegistry::getSingleton(); m_world.query().each( [&](flecs::entity e, CharacterIdentityComponent &ci) { + if (e.has()) + return; /* regenerated from the schedule */ auto *rec = registry.findCharacter(ci.registryId); if (!rec || !e.has()) return; @@ -2106,6 +2205,17 @@ void EditorApp::saveGame(const std::string &slotPath, }; } + /* Phase 8.2 follow-up: player-touched vehicles keep their last + * pose across scene switches and saves. While inside a generated + * interior the exterior snapshot is already in the store (captured + * on entry); do not overwrite it from the interior world. */ + if (m_activeGeneratedInterior.empty()) + captureVehicleStates(); + saveData["vehicleState"] = VehicleStateStore::getInstance().serialize(); + if (m_npcTrafficSystem) + saveData["stolenVehicles"] = + m_npcTrafficSystem->serializeStolenCars(); + saveData["characterRegistry"] = registry.serialize(); saveData["containerState"] = ContainerStateRegistry::getInstance().serialize(); @@ -2125,6 +2235,8 @@ void EditorApp::saveGame(const std::string &slotPath, SceneSerializer serializer(m_world, m_sceneMgr); m_world.query().each( [&](flecs::entity e, const RuntimeMarkerComponent &) { + if (e.has()) + return; if (e.has()) return; if (e.has()) @@ -2144,6 +2256,8 @@ void EditorApp::saveGame(const std::string &slotPath, }; m_world.query().each( [&](flecs::entity e, CharacterIdentityComponent &ci) { + if (e.has()) + return; nlohmann::json entityJson = serializer.serializeEntity(e); nlohmann::json filtered; @@ -2428,7 +2542,22 @@ void EditorApp::loadGame(const std::string &slotPath) return; } + /* Phase 8.2 follow-up: restore the vehicle snapshot and the + * carjack ledger before the base scene loads, so authored cars + * land at their saved poses and stolen owners stay carless. */ + if (saveData.contains("vehicleState")) + VehicleStateStore::getInstance().deserialize( + saveData["vehicleState"]); + else + VehicleStateStore::getInstance().clear(); + if (m_npcTrafficSystem) + m_npcTrafficSystem->deserializeStolenCars( + saveData.value("stolenVehicles", + nlohmann::json::array())); + clearScene(); + if (m_interiorNpcSystem) + m_interiorNpcSystem->clear(); /* The loaded scene replaces the one the player was in: any * generated interior is gone until the re-entry below. */ m_activeGeneratedInterior.clear(); @@ -2452,6 +2581,7 @@ void EditorApp::loadGame(const std::string &slotPath) } PrefabSystem prefabSys(m_world, m_sceneMgr); prefabSys.resolveInstances(); + applyVehicleStates(baseScene); SceneScriptSystem::loadPendingScripts(m_world); SceneScriptSystem::sendSceneLoaded(baseScene); @@ -2518,6 +2648,12 @@ void EditorApp::setupECS() // Register Primitive and TriangleBuffer components m_world.component(); + + /* Phase 8.2 follow-up: player-touched vehicle persistence and + * carjack ownership. */ + m_world.component(); + m_world.component(); + m_world.component(); m_world.component(); // Register CharacterSlots component @@ -2940,7 +3076,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) * the registry position at the last symbolic car position * for one frame (motion already ran with the NPC still * suspended) instead of the pure walk-layer position --- */ - if (m_npcTrafficSystem) { + if (m_npcTrafficSystem && m_activeGeneratedInterior.empty()) { m_npcTrafficSystem->update(fixedEvt.timeSinceLastFrame); } stepProfile("npcTraffic"); @@ -2957,7 +3093,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) * symbolic motion update so it can override suspended * NPCs, and before the detail tier so its drive targets * are fresh) --- */ - if (m_npcTaxiSystem) { + if (m_npcTaxiSystem && m_activeGeneratedInterior.empty()) { m_npcTaxiSystem->update(fixedEvt.timeSinceLastFrame); } stepProfile("npcTaxi"); @@ -2970,7 +3106,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) * frame). A despawn persists the entity's live position * into the registry record; the next frame's pure * symbolic evaluation overwrites it. --- */ - if (m_npcDetailSystem) { + if (m_npcDetailSystem && m_activeGeneratedInterior.empty()) { m_npcDetailSystem->update(fixedEvt.timeSinceLastFrame); } stepProfile("npcDetail"); @@ -2978,7 +3114,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) /* --- NPC local avoidance (phase 4.9.3): halts blocked * pedestrians; runs right after the detail tier so halts * apply to freshly-driven targets --- */ - if (m_npcAvoidanceSystem) { + if (m_npcAvoidanceSystem && m_activeGeneratedInterior.empty()) { m_npcAvoidanceSystem->update(); } stepProfile("npcAvoidance"); @@ -3127,12 +3263,15 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) * 4.7.7) — same post-physics-sync timing as the driver * pin above. */ if (m_npcTaxiSystem) { - m_npcTaxiSystem->postPhysicsUpdate(); + if (m_npcTaxiSystem && m_activeGeneratedInterior.empty()) + m_npcTaxiSystem->postPhysicsUpdate(); } /* NPC traffic drivers pinned to their seats (demo-taxi * phase 5.4) — same post-physics-sync timing. */ if (m_npcTrafficSystem) { - m_npcTrafficSystem->postPhysicsUpdate(); + if (m_npcTrafficSystem && + m_activeGeneratedInterior.empty()) + m_npcTrafficSystem->postPhysicsUpdate(); } if (m_playerControllerSystem) { m_playerControllerSystem->postPhysicsUpdate( diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index e2e7bb3..0790d75 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -19,6 +19,7 @@ // Forward declarations class EditorUISystem; class GeneratedInteriorService; +class InteriorNpcSystem; class VehicleSystem; class AiDriverSystem; class ZoneSystem; @@ -204,6 +205,7 @@ public: /* Pin the per-frame delta (seconds) for deterministic headless * tests; 0 (default) uses the wall-clock delta. */ void setFixedDeltaTime(float dt) { m_fixedDeltaTime = dt; } + float getFixedDeltaTime() const { return m_fixedDeltaTime; } // OgreBites::InputListener overrides bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override; @@ -497,6 +499,10 @@ public: { return m_generatedInteriorService.get(); } + InteriorNpcSystem *getInteriorNpcSystem() const + { + return m_interiorNpcSystem.get(); + } /* Phase 8.2: non-empty while the player is inside a generated * interior ("generated:")); the base scene stays the * exterior one for saves. */ @@ -512,6 +518,12 @@ public: { return m_activeGeneratedSourceScene; } + /* The scene saves are anchored to (the exterior while inside a + * generated interior). */ + const Ogre::String &getCurrentBaseScene() const + { + return m_currentBaseScene; + } Ogre::ImGuiOverlay *getImGuiOverlay() const { return m_imguiOverlay; @@ -570,6 +582,7 @@ private: std::unique_ptr m_actuatorSystem; std::unique_ptr m_doorSystem; std::unique_ptr m_generatedInteriorService; + std::unique_ptr m_interiorNpcSystem; std::unique_ptr m_standaloneDoorSystem; std::unique_ptr m_eventHandlerSystem; std::unique_ptr m_itemSystem; @@ -636,6 +649,10 @@ private: * a normal file scene is active). The BASE scene keeps pointing * at the exterior scene for saves. */ Ogre::String m_activeGeneratedInterior; + /* Scene ambient saved on interior entry and restored on exit + * (generated interiors declare their own ambient). */ + Ogre::ColourValue m_interiorSavedAmbient; + bool m_interiorSavedAmbientValid = false; Ogre::String m_activeGeneratedSourceScene; /* Grounding watchdog: after a game-mode scene switch the terrain @@ -699,6 +716,12 @@ private: void processPendingInteriorReentry(); void applyLoadedSaveState(const nlohmann::json &saveData, const std::string &slotPath); + /* Phase 8.2 follow-up: player-touched vehicle persistence. */ + void captureVehicleStates(); + /* Phase 8.2 follow-up: schedule-driven NPCs in a generated + * interior (the exterior detail tier skips indoor NPCs). */ + void populateInteriorNpcs(); + void applyVehicleStates(const Ogre::String &scenePath); bool performSceneSwitch(const Ogre::String &scenePath, const SceneSwitchOptions &opts); void destroySceneEntities(); diff --git a/src/features/editScene/components/Character.hpp b/src/features/editScene/components/Character.hpp index 9d0a989..3cadd5d 100644 --- a/src/features/editScene/components/Character.hpp +++ b/src/features/editScene/components/Character.hpp @@ -51,6 +51,22 @@ struct CharacterComponent { float floorCheckDistance = 2.0f; bool useGravity = true; + /* Vertical dynamics (phase 8.4): game-feel gravity multiplier and a + * terminal velocity cap. The cap keeps the per-step travel below + * the capsule diameter so fast falls cannot tunnel through thin + * streamed terrain colliders; the character body also uses linear + * cast motion quality (see CharacterSystem). */ + float gravityScale = 1.35f; + float maxFallSpeed = 25.0f; + + /* Controller-level step-up (phase 8.4): when a grounded character + * is blocked by a STATIC ledge no higher than this (curbs, + * sidewalks, thresholds, low platforms), CharacterSystem lifts the + * capsule onto it. Stairs are unaffected: the capsule already + * climbs their steps (the horizontal move is not blocked), so the + * assist only fires on a blocked move. 0 disables the assist. */ + float maxStepHeight = 0.4f; + /* 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 diff --git a/src/features/editScene/components/InteriorNpc.hpp b/src/features/editScene/components/InteriorNpc.hpp new file mode 100644 index 0000000..eb6f35e --- /dev/null +++ b/src/features/editScene/components/InteriorNpc.hpp @@ -0,0 +1,12 @@ +#pragma once + +/** + * Tag for the temporary NPC entities spawned inside a generated + * interior from the population schedule (demo-taxi phase 8.2 + * follow-up). They are idle placeholders for v1: no behavior tree, no + * scheduling and no interior pathfinding. Never saved — the interior + * is regenerated from the plan on the next visit, and the population + * registry keeps the exterior symbolic state. + */ +struct InteriorNpcTag { +}; diff --git a/src/features/editScene/components/Vehicle.hpp b/src/features/editScene/components/Vehicle.hpp index c25fc5b..f2e55a4 100644 --- a/src/features/editScene/components/Vehicle.hpp +++ b/src/features/editScene/components/Vehicle.hpp @@ -184,4 +184,25 @@ struct VehicleComponent { bool constraintCreated = false; }; +/** + * Runtime ownership of a vehicle (phase 8.2 follow-up). + * + * Set when the player takes a car from the NPC traffic fleet: ownerId is + * the driver's registry id (0 = unowned / scene-authored). The car + * leaves the fleet and the owner walks for the rest of the in-game day; + * the next day the fleet assigns a fresh car. Never written to scene + * files — VehicleStateStore carries it in save games. + */ +struct VehicleOwnershipComponent { + uint64_t ownerId = 0; +}; + +/** + * Tag: the player has entered this vehicle at least once. Its last pose + * is snapshotted by VehicleStateStore on scene switches / saves and + * restored when the owning scene loads. + */ +struct PlayerTouchedVehicleTag { +}; + #endif // EDITSCENE_VEHICLE_HPP diff --git a/src/features/editScene/demos/demo-taxi/CMakeLists.txt b/src/features/editScene/demos/demo-taxi/CMakeLists.txt index 798dc51..b5eeeee 100644 --- a/src/features/editScene/demos/demo-taxi/CMakeLists.txt +++ b/src/features/editScene/demos/demo-taxi/CMakeLists.txt @@ -148,6 +148,10 @@ target_include_directories(demoTaxi PRIVATE ) # Stage the standalone runtime next to the executable (see header comment). +# Link dependencies make edits to the menu/config re-run the POST_BUILD. +set_property(TARGET demoTaxi APPEND PROPERTY LINK_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/startup_menu.json" + "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake") add_custom_command(TARGET demoTaxi POST_BUILD COMMAND ${CMAKE_COMMAND} -DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR} diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index e18ed9f..46672f8 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -3252,11 +3252,13 @@ broke `--test-motion`'s bit-exact reproducibility. The catalog's layer-0 ("nude base") meshes for the `top`/`bottom`/`feet` slots are simplified composition bases meant to disappear under layer-1/2 garments — they lack anatomical - detail, which shows when a character wears nothing (outfitLevel 0). - `CharacterSlotSystem::resolveMesh` now prefers a layer-0 entry - tagged `detailed-nude` at outfitLevel 0; slots without a detailed - variant keep the current composition base as the fallback, so the - parts can be authored one slot at a time. + detail, which shows when a character wears nothing. Detailed + variants live in optional `nude-.blend` source files and are + tagged `detailed-nude`; `CharacterSlotSystem::resolveMesh` prefers + them whenever no layer-1/2 garment resolves for the slot (outfit + level 0, or empty/missing garment selections). Slots without a + detailed variant keep the current composition base as the fallback, + so the parts can be authored one slot at a time. 2. Character prefabs get a `character.` file-name prefix — DONE (code side: `CharacterRegistry::generatePrefabPath` writes `prefabs/character..json`, legacy `char_.json` paths @@ -6118,15 +6120,20 @@ interior's exit doors are pre-paired with the exterior entrances: each returns the player (or NPC) to that doorway's approach point outside. Engine-level, not taxi-specific; the taxi demo is the proving ground. -**Status:** 8.1 DONE, 8.2 DONE (2026-10-30/31) — in-memory interior -documents load through the real serializer, the `generated:` -scheme resolves through the service, exterior doors are wired, the -pre-wired return pose rides DoorSystem, the LRU cache exists, and a -save taken inside an interior re-enters it on load (deferred until the -streamed town rebuilds the building + plan, then the save snapshot is -restored on the interior). 8.3-8.6 planned. Phase 7 (stairs, -automatic stairwells, wide flights) provides the multi-floor traversal -this phase depends on. +**Status:** 8.1 DONE, 8.2 DONE + follow-ups DONE (2026-10-30/31) — +in-memory interior documents load through the real serializer, the +`generated:` scheme resolves through the service, exterior +doors are wired, the pre-wired return pose rides DoorSystem, the LRU +cache exists, and a save taken inside an interior re-enters it on load +(deferred until the streamed town rebuilds the building + plan, then +the save snapshot is restored on the interior). Follow-ups: interiors +are complete standalone rooms (inward perimeter walls/windows, +intermediate ceilings, automatic stairwells, furnished rooms); +player-touched vehicles keep their last pose across switches/saves and +carjacked NPC cars follow the owner-replacement rule; the demo shows +its startup menu again without touching automated runs. 8.3-8.6 +planned. Phase 7 (stairs, automatic stairwells, wide flights) +provides the multi-floor traversal this phase depends on. **Requirements carried over (the original notes):** @@ -6373,6 +6380,398 @@ As built (2026-10-30/31): `demoSceneSwitchingExtra`, `demoSceneSwitching` and `demoInteriorExteriorDynamics` `--test-switch`. +#### 8.2 follow-ups — interiors, vehicles, menu — DONE (2026-10-31) + +Three demo defects reported after the first playable pass: + +**1. Interiors were incomplete.** `interiorOnly` is meant for a paired +exterior scene, so it skipped the shell, and the generated document only +copied the plan's top-floor `Ceiling` flags and no room metadata. + +As built: +- `CellGridSystem` renders `interiorOnly` as a standalone room: + - external `Wall*` flags get inward-facing planes at the internal + wall offset (internal-wall material), and the internal corner pass + maps `Wall*/Door*/Window*` onto it so perimeter corners are filled; + - external `Window*` flags get inward window frames and a glass pane + at the inner plane (previously the void showed through); + - intermediate storeys get a ceiling wherever a floor exists above + (the next floor's floor is up-facing and invisible from below); + - stair flights already built in `interiorOnly`; the missing stairs + were a data bug: `autoPlaceStairwell` carved the *grid* but the + interior plan registry kept the untouched layout plan. `TownGen + System::planFromGrid()` now snapshots the generated grid's final + flags (stairwell flags, opened walls) into the registered plan. +- `InteriorScene::build` emits room metadata children (same + `persistentId`, furniture tags/compositions, seed and flags as + `TownGenSystem::createPlanRooms()`), so the shared + `RoomLayoutSystem` furnishes generated interiors deterministically. +- The interior cache key includes the plan hash, so the stairwell + snapshot participates in invalidation. + +Coverage: the `interiorSceneLoad` terrain step now asserts inward +perimeter wall/ceiling/window/glass/stairs geometry on a real document; +`--test-interior` asserts 4 rooms, 4 stair cells and non-empty +`furnitureCells`; the new `--test-town-interior` enters a real +streamed multi-storey building (2 floors, 84 cells, 4 rooms, 4 stair +cells, 7 furniture placements at the time of writing) and exits at the +pre-wired pose. + +**2. Player-touched vehicles reset on load.** Scene switches destroy +every scene entity and a save reload re-reads the scene file, so +authored cars snapped back to authored poses; carjacked NPC cars were +runtime entities that died with the scene. + +As built: +- New `VehicleStateStore` (save-backed session store, scene-keyed): + snapshots every vehicle carrying `PlayerTouchedVehicleTag` (added by + `VehicleControllerSystem::enterVehicle`) at scene-switch/save time: + world pose, NPC owner, and — for runtime cars — the full serialized + entity. Applying it after a scene load restores authored cars by + name and re-creates carjacked runtime cars (prefab resolution is + bypassed: the component tree is complete). +- `EditorApp::performSceneSwitch` captures before the teardown and + applies after a file-scene load; `saveGame` writes + `vehicleState` + `stolenVehicles`; `loadGame` restores both before + loading the base scene and applies the cars after it; `startNewGame` + clears them. Saving inside a generated interior reuses the snapshot + taken when entering (the exterior is not observable from there). +- Carjacked NPC cars (`NpcTrafficSystem`): the car is renamed + `stolen___` and tagged with + `VehicleOwnershipComponent{ownerId}`; the owner walks for the rest of + the theft day and gets a fresh car on the next in-game day + (`seedArea()` re-runs, adding only missing drivers; the abandoned car + stays where the player left it). The stolen ledger is serialized in + saves; on load a fresh-process clock is normalized so the theft + counts as "today". No theft consequences yet — the police mechanic + comes later. + +Coverage: `--test-interior` takes a sedan through a real enter/exit, +leaves it at a custom pose, and verifies the pose across the interior +round trip AND the save/load re-entry; the new `--test-stolen-car` +takes a high-detail NPC car, checks the fleet/ownership contract, +saves/loads (pose + ledger restored, owner still walking), advances a +day and checks the replacement car plus the abandoned car's pose. + +**3. No startup menu in the demo.** The embedded project skipped the +menu (`gameMode` + `startScene`) and the demo always called +`startNewGame()`. + +As built: `demoTaxi` clears the project `startScene` for interactive +runs (no `--headless`, no `--test-*`, no benchmark/capture/debug flag, +no positional scene), so `EditorApp::setup` loads the demo-owned +`startup_menu.json` (new source file, staged by POST_BUILD; NEW GAME -> +the demo's project start scene, `demo_scene_interior.json`). Automated +runs keep the direct start, so +every battery is unchanged. The POST_BUILD staging now re-runs when +`startup_menu.json`/`stage_runtime.cmake` change. + +Follow-up fix (clicking NEW GAME failed): the button calls +`startNewGame(sm.newGameScene)`, and `startNewGame`'s `clearScene()` +destroys the menu entity that owns that string — the reference dangled +through the load and the serializer read a freed filename +(`Failed to open file for reading: json`). `startNewGame` now +copies the path before the teardown. Regression: the new +`--test-menu-new-game` battery builds the same component-owned path, +calls `startNewGame` and asserts the menu entity is gone, the state is +Playing, the base scene is the menu scene and its content (sedan1) +exists. + +#### 8.2 follow-ups 2 — interior visual + NPC pass — DONE (2026-11-01) + +Second playable-pass defect list; all four are fixed: + +**Missing wall strips / black ceilings.** The inward-shell pass had only +landed for windows and ceilings; the perimeter wall planes themselves +were still absent (the interior grid showed `extWall=0`, `intWall` only +the interior room partitions). As built: +- `buildWalls` now emits inward perimeter planes at the internal wall + plane/height for every external `Wall*` flag, spanning the FULL cell + width so adjacent cells meet exactly (the inset internal-wall geometry + left thin 0.2 m gaps at cell boundaries). +- `buildWindows`/`buildDoors` mapped perimeter frames/surrounds also use + the full cell width; the door surround is built at the inner plane + (the outward ext-door panels were invisible from inside, leaving the + lintel/side strips open). +- The internal-window mesh was created TWICE per build (the second block + after extWindows), so every rebuild logged `Mesh ... already exists` + and discarded the new buffer, leaving stale/partial window geometry. + The duplicate block was removed. +- Ceilings are now thin closed slabs for every `interiorOnly` cell + (except stair cells), including cells with no storey above (annexes / + cut footprints); the single down-facing plane was invisible from below + in the RTSS path. A second dim upward directional light + (`interior-light-ceiling`) lights the underside (the main light points + down and leaves ceilings black). +- The `interiorSceneLoad` terrain probe still asserts inward + wall/ceiling/window/glass/stair geometry; the road probe expects the + two interior lights; `--test-town-interior` enters a real streamed + building. + +**Furniture invisible / phantom colliders.** The duplicate intWindows +mesh made every grid rebuild fail that part; the same rebuild kept the +old collider placements while re-emitting furniture, which read as +invisible blockers. With the duplicate removed, the 7/8 furniture +placements in the test building render (table + chairs, beds, kitchen +counters), and `getFurniturePlacements()` matches the visible set; the +town probe asserts both counts. + +**Stairs against the wall / hard to climb.** `autoPlaceStairwell` used +the first fitting row/column, which is almost always the facade row; it +now scores candidates by distance from the perimeter and centrality +(interior runs win, deterministic tie-break). Step geometry now targets +the walk-speed envelope: `STAIR_RISE_MAX = 0.14` and +`STAIR_TREAD = 0.25` (a 4 m storey = 29 steps of ~0.138 m rise), exposed +as public CellGridSystem constants so `--test-stairs` derives the +first-step top instead of hardcoding it. + +**No NPCs inside.** `NpcDetailSystem` deliberately skips indoor +locations, so generated interiors were empty. New +`InteriorNpcSystem` (EditorApp-owned) runs after a generated interior +document loads: it collects population NPCs whose current motion state +is AtLocation at an indoor location owned by the building +(`gridUid` = `town-
`), spawns them via +`CharacterRegistry::spawnCharacter`, places them at room centers +(stairwell rooms excluded, deterministic spread, cap 12), gives them an +idle animation tree, and tags them `InteriorNpcTag`. They are runtime +scene content: never saved (`saveGame` skips their position override, +runtime-entity and character-runtime sections), destroyed with the +interior, and regenerated from the same schedule on the next visit. +`applyLoadedSaveState` repopulates them after a load-inside-interior +(it destroys all CharacterIdentity entities first). The exterior NPC +detail/taxi/traffic/avoidance tiers are skipped while a generated +interior is active (their coordinates belong to the exterior world). +`--test-town-interior` now requires a building with at least one +scheduled indoor NPC, sets the clock to noon, and asserts the spawned +count equals the eligible count (the test building spawns 1 of 1, and a +tagged NPC is visible in the screenshot hold probe). + +#### 8.2 follow-ups 3 — interior readability pass — DONE (2026-11-01) + +Playtest after the first follow-up round still showed: window-sized +holes in perimeter walls, black ceilings, dark rooms where furniture +reads as invisible blockers. Root causes and fixes: + +- **Ogre light direction base was wrong everywhere.** Ogre derives a + directional light's direction as -Z of the light node's orientation + (`Ogre::Light::getDerivedDirection`), but both `LightSystem` and + `EditorSunSystem` rotated from a (0,-1,0) base — every scene + directional light and the sun itself pointed somewhere other than the + authored direction (an "up" light ended up horizontal). Both now + rotate (0,0,-1) onto the desired direction. The interior ceiling + up-light (`interior-light-ceiling`) actually lights the ceilings now, + and the sun's direction is geometrically true. +- **Perimeter window surrounds.** The earlier approach mapped external + window flags onto the internal window FRAME pieces, which are only + trim around the opening — the rest of the wall stayed open (the "thin + rectangular wall parts missing"). `buildWindows` now emits the same + surround the exterior builds (full-height side pieces plus + sill/lintel strips around the window-sized opening), mirrored to the + internal wall plane; glass panes were already correct. +- **Interior ambient.** Enclosed rooms get no sky contribution, so two + directional lights left every surface facing away from them pitch + black — furniture in those rooms read as invisible ("ghost") + colliders. `InteriorScene::Lighting::ambient` (default 0.5) is stored + in the generated document (`interior.ambient`); + `EditorApp::performSceneSwitch` saves the scene ambient on entry and + restores it on exit. (The window GLASS panes are colliders by design + and are the one remaining "invisible" blocker — they block climbing + through windows.) +- Verified visually: every furniture cell of the test building + photographed up close (DEMO_TOWN_INTERIOR_HOLD=furn writes + /tmp/townfurn__.png), ceilings lit, stairs readable, + interior NPC visible; the exterior noon sun still lights the town + correctly. The road probe now also asserts `interior.ambient` and + the two-light document. + +#### 8.2 follow-ups 4 — ghost colliders: leaked static bodies — DONE (2026-11-01) + +Playtest still hit invisible-but-solid furniture/walls inside generated +interiors. A collider census near the world origin (every generated +interior is built there) proved the cause: **static Jolt bodies leaked +on every CellGrid rebuild and every scene switch**, so each visited +interior left its furniture/wall/stairs colliders behind in the physics +world, at the same coordinates the next interior is built at. + +- Cause: `CellGridSystem::destroyPhysicsColliders()` (rebuild path) and + other `entity.destruct()` paths removed the ECS entity without + destroying its Jolt body; there was no world-wide removal hook. + `EditorUISystem::deleteEntity()` only covered the entities teardown + saw, and `destroyCoord`-style direct destructs bypassed it. +- Fix: `EditorPhysicsSystem` registers an + `OnRemove` observer that destroys the Jolt body + whenever the component is removed or its entity dies, regardless of + the code path. `destroyPhysicsColliders()` also removes the body + explicitly before destructing the generated physics parent (defense + in depth, and it drops the body even if physics is wired late). +- Regression guard: `--test-town-interior` now visits the SAME building + twice by default (exit through the pre-wired pose and re-enter through + the same door) and asserts the static collider census (bodies + + compound sub-shapes near the origin) is IDENTICAL on both visits; + `DEMO_INTERIOR_VISITS=N` overrides the count for debugging. The + census is printed per visit (`[census] visit ...`). +- Verified: 4 visits in a row keep 7 bodies / 27 sub-shapes inside and + 1 body outside (while the old code grew by 5 bodies / 45 sub-shapes + per visit). This also stopped the slow physics-world growth across + every exterior/interior switch (each exterior cell grid leaked its + own static compound before). + +#### 8.3 (M1) — stacked stairwell rooms — DONE (2026-11-01) + +Playtest showed multi-floor interiors whose stairs top out against a +wall (no floor at the high end) and cut straight through upper-floor +rooms. The layout model now treats a stairwell as a proper stacked +room on every floor: + +- `layoutlib/Stairwell.{hpp,cpp}`: + - `planStairRun()` derives the flight footprint inside a stairwell + room: one cell-wide band on the low cross axis, one landing cell + kept at each end of the long axis and a walkway band beside the + flight. Rooms shorter than 5 cells keep the legacy whole-room + flight. + - `synthesizeStairwell()` (called at the end of + `LayoutEval::evaluate`) inserts the room when a multi-floor layout + has none, or completes the missing/mismatched floors when the + layout declared it on only some floors. The rect must have floor + on EVERY floor, avoid exterior door cells, prefers staying inside + one room and against a building edge (deterministic scoring); + the synthesis walls it, flags the flight cells on every flight + floor and adds one interior door per floor on a landing/walkway + cell. + - `validateStairwells()` is the layout checker: one stairwell room + per floor, identical rects, flight flagged on every flight floor, + at least one interior door per floor. Tiny footprints / legacy + compact stair rooms are allowed to fall back to the carve + (warning). +- `LayoutEval::verbRoom` now flags only the derived flight cells (on + floors with a flight); `connect`/`idoor` moves a stairwell door to + the nearest landing end when the shared-edge midpoint would open + beside the flight. +- `RoomLayoutSystem` no longer re-flags plan-derived stairwell rooms + (the plan flags the flight only; the room rect also covers the + landings + walkway). +- Shipped layouts regenerated around the stairwell: all three now use + full-width stair strips at the back on both floors - + `two-floor-house` 7 x 2 cells, `roadside-motel` 10 x 2 cells (next to + the exterior gallery stair) and `lobby-hall` 6 x 2 cells - so the + room also survives shell clamping on tight lots (see the layout audit + below). +- Verification: + - road probe: stairwell contract asserted for the default template, + all layout examples and the shipped town layouts; interior + document room set updated (4 instances incl. two stair rooms); + - TerrainTests: the auto-stairwell preview now requires stacked + stairwell rooms and runs `validateStairwells` on the plan; + - `--test-stairs`: lobby-hall run = 4 cells in one flight band, the + same room rect on both floors, an interior door on the upper + instance, and a NAVMESH PATH from the ground boarding landing up + to the upper step-off landing (climbs to y=4.34); + - `--test-town-interior`: a real streamed building must carry the + same stairwell room rect on every floor with an interior door on + the upper instance (6 rooms, 4 stair cells on the current seed). +- Visually verified in the streamed interior (ground boarding landing + facing the flight, and the upper landing/walkway/hole) with the + `DEMO_TOWN_INTERIOR_HOLD=stair` debug mode. + +**Layout audit (which layouts still carve).** A pure layoutlib audit +evaluated the default parametric template, the four built-in district +layouts, every `LayoutLibrary::examples()` entry and every +`town_layouts.txt` layout over 4 districts x 4 avail sizes (35/24/18/14 +m) x 20 seeds (plus the three shipped multi-floor layouts at 35/14/10 m +in the road probe). Before this pass one help example still carved +("Two storeys, smaller upper floor": its 3-cell-wide upper floor cannot +host a landing-plus-run room) and the shipped stair rooms shrank below +the usable length on tight lots because their fractions were relative +to the shell. Fixes: +- the three shipped multi-floor layouts now use FULL-WIDTH stair strips + (1-2 cells deep at the back): `two-floor-house` and `lobby-hall` + `(rect 0 0 1 0.333)`, `roadside-motel` `(rect 0 0 1 0.5)`. The run is + along the shell width, so the room survives shell clamping down to a + 5-cell (10 m) footprint - town's minimum multi-floor footprint; +- the "smaller upper floor" example was widened to a 16 m shell (upper + floor 3/4 of the width) and the two compact-stair examples were + rewritten to the stacked model; `connect` door placement prefers the + boarding landing on the ground floor and the step-off landing on upper + floors (a mid-edge door would open beside the flight, and a narrow + stair room without a walkway cannot cross the hole); +- `road_geometry_overlap_test` now asserts `validateStairwells()` for the + shipped layouts at avail 35/14/10 m, so tight-lot regressions fail the + gate. +Result: ZERO layouts/examples/regions carve at district sizes. A +multi-floor shell that cannot host a landing-plus-run room at all (e.g. +a 4-cell-wide shell) now falls back to **motel-style exterior stairs** +instead of the legacy carve: `addExteriorStairAccess()` gives every +upper floor a gallery door (reusing an existing exterior door when the +layout has one, else converting a boundary window/wall) and the +`ExteriorStair` flag; `validateVerticalAccess()` accepts either a +stacked stairwell or exterior stairs per floor, and +`TownGenSystem::autoPlaceStairwell()` skips carving when exterior stairs +exist (the old carve topped the flight out against a room wall). Small +authored stair rooms that are too short are demoted to normal rooms so +they cannot build that unsafe flight. The legacy carve therefore only +remains for degenerate shells with no usable facade either - no shipped +layout produces one. The single inline layout in the demo town's road +regions is single-floor (no stairwell needed). + +- Visually verified in the streamed interior (ground boarding landing + facing the flight, and the upper landing/walkway/hole) with the + `DEMO_TOWN_INTERIOR_HOLD=stair` debug mode. + +#### 8.4 — movement, falling and road materials — DONE (2026-11-01) + +Playtest found three regressions around interior exits: + +**Road loses its asphalt after leaving a building.** The road material +is a ProceduralMaterial; `ProceduralMaterialSystem` rebuilds its passes +whenever the component is dirty (which happens after the exterior scene +is recreated) and drops the atlas texture unit. `RoadSystem` re-attaches +`RoadAtlasTexture` when missing, but the RTSS shader had already been +generated without a texture sampler, so the pass rendered flat grey (the +texture unit existed, the shader ignored it). `syncRoadAtlasTextureUnit` +now rebuilds the RTSS techniques (`removeAllShaderBasedTechniques` + +`createShaderBasedTechnique` + `validateMaterial`) whenever it attaches +the atlas unit. Verified with a post-exit top-down screenshot +(`DEMO_TOWN_POST_EXIT_SHOT=1`): asphalt + sidewalk bands are back. + +**Characters cannot climb onto the road/curbs.** The character is a +rigid Jolt capsule and cannot mount even a 0.15–0.25 m curb. +`CharacterSystem` now has a controller-level step-up: when a grounded +character's horizontal move is BLOCKED (actual progress < 35% of the +requested velocity for the frame), it probes forward: +- a ray at ankle height must hit static world geometry (moving bodies - + characters/vehicles - are never stepped onto), +- a ray above `maxStepHeight` (0.4 m) must be CLEAR (walls, doors, + windows, tall furniture are not ledges), +- a downward ray just ahead must find a surface <= `maxStepHeight` above + the feet. +Then the capsule AND scene node are lifted onto the ledge (root-motion +characters write the node, so both must move). Stairs are unaffected: +the capsule already climbs their steps so the move is not blocked; the +probe only fires on genuinely blocked moves. `CharacterComponent:: +maxStepHeight` (0.4, 0 disables) is per character, so player and NPCs +share the behaviour. + +**Falls felt too slow / floaty.** Two causes: +- physics stepped with a 0.1 s delta clamp, so below 10 FPS the whole + simulation ran in slow motion (at 5 FPS everything ran at half speed); + the clamp is now 0.25 s and the fixed-step loop caps at 20 steps per + update and drops any larger backlog, so heavy frames still advance + physics in real time without a catch-up spiral; +- character gravity is now `CharacterComponent::gravityScale = 1.35` + (9.8 x 1.35), with `maxFallSpeed = 25 m/s` and the character body set + to `EMotionQuality::LinearCast` so a fast fall cannot tunnel through + thin streamed terrain colliders. +Also `JoltPhysicsWrapper` now calls `Character::PostSimulation(0.2)` (was +0.1): the capsule origin sits ~0.10 m above the floor contact, so +`IsSupported()` was permanently false while standing (ground state, +step-up gate and animation all relied on it). + +New battery `--test-fall` builds an isolated floor pad, measures the +drop acceleration (13.06 m/s^2, i.e. 9.81 x 1.35), verifies it lands +without tunneling, steps a walker onto a 0.35 m static ledge, and +verifies a 1.2 m box is NOT climbed. `--test-stairs` still passes (the +step-up assist does not disturb stair traversal). + #### 8.3 — Arrival and exit contract (pre-wired door pairs) - Each entrance doorway is paired with its interior exit door at build @@ -6448,6 +6847,98 @@ light states (lights off / day-night profiles — the `InteriorLighting` spec reserves the hook), and extending save data beyond the global store + the interior marker (doors/items/stats already persist there). +### Phase 8.3 - special rooms in the house + +For deterministic approach to the residents, lets define the following: +- The bedrooms in the house are special and require special generators: + - the lua-based furniture placer should be implemented for each room type or a set of DSL-based rules, whichever is easier. + - the room of type "master-bedroom" is required for any residential house. + - the size is at least 3 by 3 cells, should be checked while authoring the layout. + - the room has single bed attached to the wall trying to be at center. It should be attached to the wall by head or foot side. + - the room can have any other bedroom furniture, but not beds. The bed should be accessible from all sides, so one cell from a bed can't have any normal furniture, except for ceiling/floor/wall-only furniture which does not block a path. + - this room can host up to two NPCs which are considered main household pair. + - this room type is preferred to have single enterance. + - this room type is preferred as mandatory for any residential layout which is not appartment and has no 'strudio' room. + - the room of type "bedroom" is optional and holds 1 bed and 1 NPC, the minimal size is 2x2 cells + - this room can accomodate 1 NPC. + - this room type is preferred to have single enterance. + - this room can have any more bedroom furniture except beds + - the room of type "dormitory2" can accomodate 2 beds. Minimal size is 2x2 cells. All beds should be accessible. + - the beds are placed at walls with bed side attaching the wall. + - this room type is preferred to have single enterance. + - this room can have any more bedroom furniture except beds + - the room of type "dormitory4" can accomodate 4 beds. Minimal size is 3x3 cells. All beds should be accessible. + - the beds are placed at walls with bed side attaching the wall. + - this room type is preferred to have single enterance. + - this room can have any more bedroom furniture except beds + - the room of type "dormitory8" can accomodate 8 beds. Minimal size is 4x4 cells. All beds should be accessible. + - the beds are placed at walls with bed side attaching the wall. + - this room type is preferred to have single enterance. + - this room can have any more bedroom furniture except beds + - the room of type "kitchen". Requires special placement of furniture among a wall. Needs at least oven and fridge, and a few cabinets. If larger than 3x3 can host a table and chairs or other kitchen furniture, more fridges, but multiple fridges or ovens should stay behind each other. + - this room type regardless of the furniture can have wooden range with chumney through all upper floors above the roof. Upper floors furniture placement should be aware of the chimney as it occupies one furniture cell on each floor and should show on the roof. This needs to be designed properly. This should be enabled by DSL setting and room component checkbox. The chimney should be displayed by exterior view too. + - the room of type "wc" should contain exactly one toilet, optionally can have toilet paper stand and sink nearby. + - minimal size is 1x2 or 2x1. The toilet should be accessible, if there is sink, it should be accessible too. If room gets larger we place other bathroom/wc furniture in it but only if size is larger than 3x3 + - the room of type "bathroom" contains bathtub or shower or both and sink, and optionally can have toilet if size permits. The minimal size is 2x2. If size is larger than 3x3 we can place other bathroom furniture in it, but make sure access is possible. + - the room of type "bathroom2" is the same as "bathroom" but always contains toilet. + - the room of type "studio" is a mix of kitchen, livingroom, bedroom and bathroom (no toilet in there) but zoned into four square zones for furniture placement, so it behaves like three different rooms regarding furniture. Should maintain access, the same as others. The minimal size of room is 4x8 or 8x4. The toilet is always separate room. This room can accomodate 1 adult NPC ot two adult NPCs of different sex. + - the room of type "retail" contains a counter and cash register, with single worker standing behind the counter. This one belongs to the shop/place of sale. +- make sure we have all needed furniture in the library and animations for characters +### Phase 8.3 - Special buildings + +The town should have these buildings in residential area, which hold no residents but provide jobs and special services to people, +should be made with DSL and GridCells as usual. +These buildings should have priority placement and each town should have at least one of each. +- hospital + - up to 5 floors, flat roof, bug, on big lot (at least 8x16 cells). At least one per town is mandatory. + - all the internal behavioral mechanics should be written in Lua. + - primary function of the hospital is to restore NPCs and later player's health stat to 100. + - each in-game hour the NPCs (and later player) get random health increase from 0 to 10 (will change later) + - other function is for pregnant female NPCs to give birth + - the layout should be corritor-based with ward rooms holding 4 beds where NPCs reside lying on the bed all the time, no individual AIs running, until their health goes up to 100, that is when they stand up and leave the building through exit. In exterior scene they just get spawned at exit of the building in 10 seconds cooldown after their health got to 100. That will be changed later, but lets keep things simple now. + - should have the following rooms: + - reception that is where receptionist will work, contains counter and a few chairs. Just one on lowest floor. + - corridoors connect reception and bottom floor wards, have wards on both sides of the corridoor if possible. + - at least one stairwell per long corridoor. + - wards contain only 4 beds and nightstands, no other furniture. While present in the hospital NPCs just lie there. Same group NPCs prefer to be there. + - operational rooms with medical furniture, purely decorational for now. + - birth wards, pregnant female NPCs spend last few days of pregnancy there before leaving with baby. All they do is lie in bed for now. 4 beds per ward. + - doctor's cabinets, have chair, desk, some stuff on the desk. + - public restrooms, a few toilet stalls and a few sinks + - about 5 layout variations should be generated. + - make sure we have all necessary furniture and animations +- police station + - up to two floors + - all the internal behavioral mechanics should be written in Lua. + - primary function is to provide jobs for police and maintain the group. + - the structure is reception - corridoors - offices + - reception has a counter with one counter worker + - corridiirs with offices on both sides + - stairwell on each end of the corridoor + - office have 4 desks and 4 chairs each + - public restrooms, a few toilet stalls and a few sinks, at ends of corridoors + - about 5 layout variations should be generated. + - make sure we have all necessary furniture and animations + - this one will drive the plot + - taxi office + - two floors + - bottom floor is garage, top is office + - school + - at least one per town + - up to 4 floors + - children and teenagers go to school and spend 5 days in week there. + - children in school just sit behind desks in classrooms until their school schedule ends then they leave through exit + - structure - lobby - corridoor - offices and classrooms + - rooms: + - lobby + - cafe + - classroom + - office + - corridoor + - club + - ??? + - gym + - ??? ### Phase 8.5 — Population, residents & schedule POIs Population/resident control for towns, areas and layouts (follow-up @@ -6598,11 +7089,13 @@ per-NPC picks) — the work is content, tooling and retargeting: deterministic per-NPC pick (`fnv1a(npcKey|slot)`) so looks stay stable across sessions. - **Detailed base parts (moved from 6.C.1).** Author the layer-0 - `detailed-nude` variants for top/bottom/feet in the character blends, - following `assets/blender/characters/CHARACTER_MODELING.md` §3. The - runtime preference and composition-base fallback already landed in - Phase 6; this item is content only. Verify outfitLevel 0 resolves the - detailed variants and clothed characters are unchanged. + `detailed-nude` variants for top/bottom/feet in the per-stem + `nude-.blend` source files (`init-nude-` seeds them from + the existing base parts), following + `assets/blender/characters/CHARACTER_MODELING.md` §3. The runtime + preference and composition-base fallback already landed in Phase 6; + this item is content only. Verify empty/outfit-level-0 selections + resolve the detailed variants and clothed characters are unchanged. - **Character prefab file migration (moved from 6.C.2).** Rename the remaining existing `char_.json` assets to `character..json`, update demo/staging references deliberately, and exercise the existing diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 10ed855..07d3de3 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -25,7 +25,10 @@ #include "systems/NavMeshSystem.hpp" #include "components/NavMesh.hpp" #include "systems/GeneratedInteriorService.hpp" +#include "systems/VehicleStateStore.hpp" +#include "systems/InteriorNpcSystem.hpp" #include "layoutlib/LayoutEval.hpp" +#include "layoutlib/Stairwell.hpp" #include "systems/ActuatorSystem.hpp" #include "components/CellGrid.hpp" #include "components/Actuator.hpp" @@ -49,7 +52,11 @@ #include "systems/GameClockSystem.hpp" #include "components/AiDriver.hpp" #include "components/Door.hpp" +#include "components/EditorMarker.hpp" +#include "components/Renderable.hpp" +#include "components/InteriorNpc.hpp" #include "components/EntityName.hpp" +#include "components/StartupMenu.hpp" #include "components/Transform.hpp" #include "components/RigidBody.hpp" #include "components/Vehicle.hpp" @@ -1261,8 +1268,70 @@ struct AgeTestListener : public Ogre::FrameListener { .resourceExists("Characters", lod2)) return fail("missing LOD2 impostor " + lod2); } + /* Detailed-nude preference: every catalog slot with a + * detailed entry must resolve to it for a record with + * no layer-1/2 garment selected, both at outfit level + * 0 and at outfit level 2. */ + const nlohmann::json &cat = + CharacterSlotSystem::getCatalog(); + const char *dnAges[] = { "baby", "child", "teenager", + "adult", "elder" }; + const char *dnSexes[] = { "male", "female" }; + const char *dnSlots[] = { "top", "bottom", "feet" }; + int detailedChecks = 0; + for (const char *a : dnAges) { + for (const char *s : dnSexes) { + for (const char *slot : dnSlots) { + if (!cat.contains(a) || + !cat[a].contains(s) || + !cat[a][s].contains(slot)) + continue; + std::string detailed; + for (const auto &entry : + cat[a][s][slot]) { + for (const auto &t : + entry.value( + "tags", + nlohmann::json:: + array())) { + if (t.get() == + "detailed-nude") + detailed = + entry.value( + "mesh", + ""); + } + } + if (detailed.empty()) + continue; + ++detailedChecks; + SlotSelection sel; + const Ogre::String r0 = + CharacterSlotSystem:: + resolveMesh( + a, s, slot, + sel, 0); + const Ogre::String r2 = + CharacterSlotSystem:: + resolveMesh( + a, s, slot, + sel, 2); + if (r0 != detailed || + r2 != detailed) + return fail( + std::string( + "detailed-nude not resolved for ") + + a + "/" + s + "/" + + slot + " (l0='" + r0 + + "', l2='" + r2 + + "', want='" + + detailed + "')"); + } + } + } std::cout << "[test] PASS: age bands built (" - << entities.size() << " characters)" + << entities.size() << " characters, " + << detailedChecks << " detailed-nude slots)" << std::endl; phase = 2; for (uint64_t id : ids) @@ -2097,7 +2166,7 @@ struct DeterminismTestListener : public Ogre::FrameListener { int phase = 0; bool failed = false; Ogre::String failReason; - int deadline = 20000; + int deadline = 200000; std::string snapshot; DeterminismTestListener(EditorApp *a) @@ -5244,7 +5313,12 @@ struct StairsTestListener : public Ogre::FrameListener { } tgs.clearLayoutPreview(); - /* Lobby hall: one wide grand flight. */ + /* Lobby hall (phase 8.3): a stacked stairwell room + * on both floors. The flight occupies the low-x + * band, one cell wide, with a walkway beside it + * and a landing at each end; the upper floor + * instance is a normal room with an interior + * door. */ if (!tgs.previewLayout("lobby-hall", &lib, 0, 7, Ogre::Vector3(0, 0, 120), "full", &err)) { @@ -5253,12 +5327,77 @@ struct StairsTestListener : public Ogre::FrameListener { } cellSys->update(); b = lastGrid(); - st = b.is_alive() && b.has() ? - demoAttachedMesh( - b.get().node, - "_stairs") : - Ogre::MeshPtr(); - const float firstTop = 0.2f + 4.0f / 23.0f; + if (!b.is_alive()) { + fail("lobby-hall preview created no grid"); + return true; + } + { + const CellGridComponent &g = + b.get(); + int runCells = 0; + for (const Cell &c : g.cells) + if ((c.flags & CellFlags::StairWell) && + c.y == 0) + ++runCells; + if (runCells != 4) { + fail("lobby-hall stair run has " + + std::to_string(runCells) + + " cells (expected 4)"); + return true; + } + const RoomComponent *stairLower = nullptr; + const RoomComponent *stairUpper = nullptr; + b.children([&](flecs::entity ch) { + if (!ch.has()) + return true; + const auto &r = + ch.get(); + if (r.roomType != "stairwell") + return true; + if (r.minY == 0) + stairLower = &r; + if (r.minY == 1) + stairUpper = &r; + return true; + }); + if (!stairLower || !stairUpper) { + fail("lobby-hall has no stacked " + "stairwell rooms"); + return true; + } + if (stairLower->minX != stairUpper->minX || + stairLower->minZ != stairUpper->minZ || + stairLower->maxX != stairUpper->maxX || + stairLower->maxZ != stairUpper->maxZ) { + fail("lobby-hall stairwell rooms " + "differ between floors"); + return true; + } + bool upperDoor = false; + for (const Cell &c : g.cells) + if (c.y == 1 && + c.x >= stairUpper->minX && + c.x < stairUpper->maxX && + c.z >= stairUpper->minZ && + c.z < stairUpper->maxZ && + (c.flags & + (CellFlags::IntDoorXNeg | + CellFlags::IntDoorXPos | + CellFlags::IntDoorZPos | + CellFlags::IntDoorZNeg))) + upperDoor = true; + if (!upperDoor) { + fail("upper stairwell room has no " + "interior door"); + return true; + } + } + st = demoAttachedMesh( + b.get().node, "_stairs"); + const int stairSteps = (int)std::ceil( + 4.0f / CellGridSystem::STAIR_RISE_MAX); + const float firstTop = + 0.2f + 4.0f / (float)stairSteps; float zMin = 1.0e9f, zMax = -1.0e9f; demoScanMesh(st, [&](const Ogre::Vector3 &v) { if (std::fabs(v.y - firstTop) < 0.01f) { @@ -5266,10 +5405,109 @@ struct StairsTestListener : public Ogre::FrameListener { zMax = std::max(zMax, v.z); } }); - if (st.isNull() || zMax - zMin < 3.4f) { - fail("lobby-hall has no wide lobby stair"); + if (st.isNull() || zMax - zMin < 1.2f || + zMax - zMin > 2.2f) { + fail("lobby-hall first step is not one " + "cell wide"); return true; } + /* Navmesh: the stacked stairwell must connect the + * ground-floor boarding landing to the upper-floor + * step-off landing THROUGH the flight (no wall, + * no missing floor). */ + NavMeshSystem *navSys = app->getNavMeshSystem(); + if (!navSys || !navE.is_alive()) { + fail("no NavMeshSystem for the lobby stair"); + return true; + } + { + const CellGridComponent &g2 = + b.get(); + const RoomComponent *lo = nullptr; + const RoomComponent *up = nullptr; + b.children([&](flecs::entity ch) { + if (!ch.has()) + return true; + const auto &r = + ch.get(); + if (r.roomType != "stairwell") + return true; + if (r.minY == 0) + lo = &r; + if (r.minY == 1) + up = &r; + return true; + }); + if (!lo || !up) { + fail("lobby-hall lost its stacked " + "stairwell rooms"); + return true; + } + navSys->rebuild(navE); + const Ogre::Vector3 origin = + b.get() + .node->_getDerivedPosition(); + const float cs = g2.cellSize; + const float chh = g2.cellHeight; + /* Boarding and step-off landings come from + * the derived run (ground = low end, upper + * = the landing the flight tops out on). */ + const LayoutLib::StairRun run = + LayoutLib::planStairRun( + lo->minX, lo->minZ, + lo->maxX - 1, lo->maxZ - 1); + int gx = lo->minX, gz = lo->minZ; + int ux = up->minX, uz = up->minZ; + if (run.valid) { + if (run.alongX) { + gx = run.x0 - 1; + gz = run.z0; + ux = run.x1 + 1; + uz = run.z0; + } else { + gx = run.x0; + gz = run.z0 - 1; + ux = run.x0; + uz = run.z1 + 1; + } + } + const Ogre::Vector3 ground = + origin + + Ogre::Vector3( + (float)gx * cs, + CellGridSystem:: + floorSurfaceY() + + 0.7f, + (float)gz * cs); + const Ogre::Vector3 upper = + origin + + Ogre::Vector3( + (float)ux * cs, + chh + CellGridSystem:: + floorSurfaceY() + + 0.7f, + (float)uz * cs); + std::vector path; + if (!navSys->findPath(navE, ground, upper, + path) || + path.empty() || + (path.back() - upper).length() > 1.5f) { + fail("no navmesh path through the " + "stacked stairwell"); + return true; + } + float pathMaxY = -1.0e9f; + for (const auto &p : path) + pathMaxY = std::max(pathMaxY, p.y); + if (pathMaxY < origin.y + chh - 1.0f) { + fail("stairwell path did not climb"); + return true; + } + std::cout << "[test] stairs: stacked " + "stairwell navmesh path " + "climbs to y " + << pathMaxY << std::endl; + } tgs.clearLayoutPreview(); std::cout << "[test] PASS: stairs are walkable and " @@ -5285,6 +5523,1935 @@ struct StairsTestListener : public Ogre::FrameListener { } }; +/* + * --test-menu-new-game (phase 8.2 follow-up): startup menu NEW GAME. + * + * Reproduces the menu path exactly: startNewGame() is called with a + * string owned by a StartupMenuComponent entity (like the menu's + * NEW GAME button), and that entity is destroyed by clearScene() inside + * the call. startNewGame must copy the path before the teardown; the + * regression was a dangling reference that read freed memory as the + * scene filename ("Failed to open file for reading: json"). + * + * Run: ./demoTaxi --headless --test-menu-new-game + */ +struct MenuNewGameTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 6000; + + explicit MenuNewGameTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const std::string &why) + { + if (failed) + return true; + failed = true; + failReason = why; + std::cerr << "[test] menu new game FAIL: " << why + << std::endl; + app->getRoot()->queueEndRendering(); + return true; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + ++frame; + if (failed || phase == 2) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > deadline) + return fail("timeout in phase " + + std::to_string(phase)); + + switch (phase) { + case 0: { + if (!editScene::isGamePlaying() || frame < 5) + return true; + /* Same shape as startup_menu.json: an editor-marked + * entity owning the StartupMenuComponent. */ + flecs::world *w = app->getWorld(); + flecs::entity menu = w->entity(); + menu.add(); + menu.set( + EntityNameComponent("menu-test")); + StartupMenuComponent sm; + sm.newGameScene = "demo_scene_vehicletest.json"; + menu.set(sm); + std::cout << "[test] menu new game: calling " + "startNewGame with the component-owned " + "path" << std::endl; + /* The reference is destroyed by clearScene() inside + * startNewGame(); the path must survive. */ + StartupMenuComponent &ref = + menu.get_mut(); + app->startNewGame(ref.newGameScene); + if (menu.is_alive()) + return fail("menu entity survived startNewGame"); + if (app->getGamePlayState() != + EditorApp::GamePlayState::Playing) + return fail("NEW GAME did not enter Playing"); + if (app->getCurrentBaseScene() != + "demo_scene_vehicletest.json") + return fail("NEW GAME loaded '" + + std::string( + app->getCurrentBaseScene()) + + "' instead of the menu scene"); + flecs::entity sedan; + w->each([&](flecs::entity e, + EntityNameComponent &n) { + if (!sedan.is_alive() && + e.has() && + n.name == "sedan1") + sedan = e; + }); + if (!sedan.is_alive()) + return fail("menu scene content missing " + "after NEW GAME"); + std::cout << "[test] PASS: menu NEW GAME loaded " + "demo_scene_vehicletest.json" + << std::endl; + phase = 2; + app->getRoot()->queueEndRendering(); + return true; + } + } + return true; + } +}; + +/* + * --test-town-interior (phase 8.2 follow-up): real generated building. + * + * Waits for the streamed town to build a multi-storey generated + * building, enters it through its generated-interior scene switch and + * verifies the inside is complete: the grid matches the plan, rooms + * were materialized (furniture), the automatic stairwell exists on the + * upper floors, and the interior door returns to the pre-wired exterior + * approach pose. + * + * Run: ./demoTaxi --headless --test-town-interior + */ +struct TownInteriorTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 60000; + int wait = 0; + std::string gridUid; + std::string doorKey; + std::string scheme; + Ogre::Vector3 exitPos = Ogre::Vector3::ZERO; + float exitYaw = 0.0f; + int expectedCells = 0; + int expectedStairs = 0; + int expectedNpcs = 0; + bool multiStorey = false; + bool clockSet = false; + /* Debug aid: DEMO_TOWN_INTERIOR_HOLD=1 pauses inside the interior + * and writes /tmp/townint_.png while turning the camera, for + * visual inspection of walls/ceilings/furniture/NPCs. Set + * DEMO_TOWN_INTERIOR_KEEP_NPC_ROOM=1 to frame the first NPC. */ + bool holdInit = false; + int holdFrame = 0; + int holdShots = 0; + /* DEMO_TOWN_INTERIOR_HOLD=furn photographs every furniture cell: + * the player is placed DEMO_FURN_DIST metres away, facing the + * cell centre, and /tmp/townfurn_.png is written. */ + std::vector holdFurn; + int holdFurnIdx = -1; + /* DEMO_TOWN_INTERIOR_HOLD=stair: two screenshots of the stacked + * stairwell (boarding landing, then the upper step-off landing). */ + int holdStairStage = 0; + Ogre::Vector3 holdStairPos[2]; + float holdStairYaw[2] = { 0.0f, 0.0f }; + /* Static-body census near the origin, where every generated + * interior is built. */ + struct ColliderCensus { + int bodies = 0; + int subShapes = 0; + }; + /* DEMO_INTERIOR_VISITS=N: enter the same building N times + * (exit + re-enter); a grown census means leaked bodies (the + * next interior would show them as ghost colliders). */ + int maxVisits = 2; + int visits = 0; + ColliderCensus firstCensus; + bool postExitShot = false; + + explicit TownInteriorTestListener(EditorApp *a) + : app(a) + { + if (const char *v = std::getenv("DEMO_INTERIOR_VISITS")) + maxVisits = std::max(1, atoi(v)); + } + + bool fail(const std::string &why) + { + if (failed) + return true; + failed = true; + failReason = why; + std::cerr << "[test] town interior FAIL: " << why + << std::endl; + app->getRoot()->queueEndRendering(); + return true; + } + + void logRoadMaterials(const char *tag) + { + int meshes = 0; + app->getWorld()->each( + [&](flecs::entity, RenderableComponent &r) { + if (r.meshName.rfind("Road_", 0) != 0) + return; + Ogre::MeshPtr mesh = + Ogre::MeshManager::getSingleton() + .getByName(r.meshName); + if (mesh.isNull()) + return; + ++meshes; + for (unsigned si = 0; + si < mesh->getNumSubMeshes(); ++si) { + const Ogre::String &mn = + mesh->getSubMesh(si) + ->getMaterialName(); + bool exists = + Ogre::MaterialManager:: + getSingleton() + .resourceExists(mn); + size_t tex = 0; + Ogre::String texName; + if (exists) { + Ogre::MaterialPtr m = + Ogre::MaterialManager:: + getSingleton() + .getByName(mn); + if (!m.isNull() && + m->getNumTechniques() > 0) { + Ogre::Technique *t = + m->getTechnique(0); + if (t->getNumPasses() > 0) { + Ogre::Pass *ps = + t->getPass(0); + tex = ps->getNumTextureUnitStates(); + if (tex) + texName = ps->getTextureUnitState(0)->getTextureName(); + } + } + } + std::cout << "[test] town road mat " + << tag << ": " << mn + << " exists=" << exists + << " tex=" << tex << " " + << texName << std::endl; + } + }); + std::cout << "[test] town road meshes " << tag << ": " + << meshes << std::endl; + } + + Ogre::Vector3 worldPos(flecs::entity e) + { + if (!e.is_alive() || !e.has()) + return Ogre::Vector3::ZERO; + const auto &t = e.get(); + const Ogre::Vector3 rp = + t.node ? t.node->_getDerivedPosition() : t.position; + JPH::DVec3 wp(rp.x, rp.y, rp.z); + if (RenderOriginSystem *ro = + RenderOriginSystem::getInstance()) + wp = ro->renderToWorld(rp); + return Ogre::Vector3((float)wp.GetX(), (float)wp.GetY(), + (float)wp.GetZ()); + } + + /* Static collider census near the origin (generated interiors + * are built at the world origin): body count per shape type plus + * the grid body's compound sub-shape count. */ + ColliderCensus dumpCensus(const char *tag) + { + ColliderCensus census; + JoltPhysicsWrapper *phys = + JoltPhysicsWrapper::getSingletonPtr(); + if (!phys || !phys->getPhysicsSystem()) + return census; + JPH::BodyIDVector ids; + phys->getPhysicsSystem()->GetBodies(ids); + int near = 0; + int compoundSubs = 0; + int largest = 0; + for (const JPH::BodyID &id : ids) { + JPH::AABox bb = phys->getPhysicsSystem() + ->GetBodyInterface() + .GetTransformedShape(id) + .GetWorldSpaceBounds(); + if (bb.mMax.GetX() < -20.0f || + bb.mMin.GetX() > 40.0f || + bb.mMax.GetZ() < -20.0f || + bb.mMin.GetZ() > 40.0f) + continue; + const JPH::Shape *shape = phys->getPhysicsSystem() + ->GetBodyInterface() + .GetShape(id); + if (!shape) + continue; + ++near; + const int subs = + shape->GetType() == + JPH::EShapeType::Compound ? + (int)static_cast< + const JPH::CompoundShape *>( + shape) + ->GetNumSubShapes() : + 1; + compoundSubs += subs; + if (subs > largest) + largest = subs; + } + int grids = 0, interiorGrids = 0; + int rbAlive = 0, rbCreated = 0; + app->getWorld()->each([&](flecs::entity, RigidBodyComponent &rb) { + ++rbAlive; + if (rb.bodyCreated) + ++rbCreated; + }); + app->getWorld()->each([&](flecs::entity, + CellGridComponent &cg) { + ++grids; + if (cg.generationMode == "interiorOnly") + ++interiorGrids; + }); + census.bodies = near; + census.subShapes = compoundSubs; + std::cout << "[census] visit " << visits << " " << tag + << ": bodies " << near << ", sub-shapes " + << compoundSubs << ", largest compound " + << largest << ", grids " << grids << " (interior " + << interiorGrids << "), rigidbodies " << rbAlive + << " (" << rbCreated << " created)" << std::endl; + return census; + } + + /* Same schedule state the interior NPC system consumes: an NPC + * sitting at an indoor location owned by this address. */ + int eligibleInBuilding(const std::string &address) + { + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + NpcMotionSystem *motion = NpcMotionSystem::getInstance(); + if (!pop || !motion) + return 0; + int count = 0; + for (uint64_t id : pop->populationCharacterIds()) { + NpcMotionSystem::MotionInfo mi; + if (!motion->motionInfoOf(id, mi) || + mi.state != NpcMotionSystem::State::AtLocation) + continue; + const int area = pop->areaIndexOf(id); + const TownLocations::LocationSet *locs = + area >= 0 ? pop->areaLocations(area) : nullptr; + const TownLocations::NpcLocation *loc = + locs ? locs->findByName(mi.location) : nullptr; + if (!loc || loc->address != address) + continue; + if (!loc->indoor && + !TownLocations::isIndoorKind(loc->kind)) + continue; + ++count; + } + return count; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + ++frame; + if (failed || phase == 3) { + app->getRoot()->queueEndRendering(); + return true; + } + if (holdInit && std::getenv("DEMO_TOWN_INTERIOR_HOLD") && + std::getenv("DEMO_TOWN_INTERIOR_HOLD")[0] == 's') { + const int step = frame - holdFrame; + if (step > 0 && step % 90 == 0 && + holdStairStage < 2) { + Ogre::String shot = + "/tmp/townstair_" + + std::to_string(holdStairStage) + + ".png"; + if (Ogre::RenderWindow *rw = + app->getRenderWindow()) + rw->writeContentsToFile(shot); + std::cout << "[test] town interior: wrote " + << shot << std::endl; + ++holdStairStage; + if (holdStairStage >= 2) { + app->getRoot()->queueEndRendering(); + return true; + } + flecs::entity player = + app->getPlayerCharacterEntity(); + if (player.is_alive() && + player.has()) { + auto &pt = player.get_mut< + TransformComponent>(); + pt.position = + holdStairPos[holdStairStage]; + pt.applyToNode(); + if (player.has< + CharacterComponent>()) + player.get_mut< + CharacterComponent>() + .linearVelocity = + Ogre::Vector3::ZERO; + } + if (app->getPlayerControllerSystem()) + app->getPlayerControllerSystem() + ->setControllerYawPitch( + holdStairYaw + [holdStairStage], + 0.0f); + } + return true; + } + if (holdInit && holdFurnIdx >= 0) { + /* Furniture close-up mode: one screenshot per + * furniture cell, player facing the cell. */ + const int step = frame - holdFrame; + if (step > 0 && step % 90 == 0) { + flecs::entity player = + app->getPlayerCharacterEntity(); + if (holdFurnIdx < (int)holdFurn.size() && + player.is_alive() && + player.has()) { + const FurnitureCell &fc = + holdFurn[holdFurnIdx]; + Ogre::String shot = + "/tmp/townfurn_" + + std::to_string(holdFurnIdx) + + "_" + fc.furnitureType + ".png"; + Ogre::RenderWindow *rw = + app->getRenderWindow(); + if (rw) + rw->writeContentsToFile(shot); + std::cout << "[test] town interior: wrote " + << shot << std::endl; + } + ++holdFurnIdx; + if (holdFurnIdx >= (int)holdFurn.size()) { + app->getRoot()->queueEndRendering(); + return true; + } + const FurnitureCell &fc = + holdFurn[holdFurnIdx]; + flecs::entity interior; + app->getWorld()->each( + [&](flecs::entity e, + CellGridComponent &g2) { + if (!interior.is_alive() && + g2.generationMode == + "interiorOnly") + interior = e; + }); + float cs = 4.0f, ch = 4.0f; + if (interior.is_alive()) { + const auto &g2 = interior.get< + CellGridComponent>(); + cs = g2.cellSize; + ch = g2.cellHeight; + } + Ogre::Vector3 centre( + (float)fc.x * cs, + (float)fc.y * ch + + CellGridSystem:: + floorSurfaceY() + + 0.6f, + (float)fc.z * cs); + /* Stand off towards the cell centre's + * opposite side, cycling directions. */ + static const float dirs[4][2] = { + { 1.0f, 0.0f }, + { 0.0f, 1.0f }, + { -1.0f, 0.0f }, + { 0.0f, -1.0f }, + }; + const float *d = dirs[holdFurnIdx % 4]; + Ogre::Vector3 pp = centre + + Ogre::Vector3(d[0] * 2.2f, 0.3f, + d[1] * 2.2f); + auto &pt = player.get_mut< + TransformComponent>(); + pt.position = pp; + pt.applyToNode(); + if (player.has()) + player.get_mut() + .linearVelocity = + Ogre::Vector3::ZERO; + Ogre::Vector3 look = centre - pp; + float yaw = Ogre::Math::ATan2(-look.x, + -look.z) + .valueDegrees(); + if (app->getPlayerControllerSystem()) + app->getPlayerControllerSystem() + ->setControllerYawPitch( + yaw, -15.0f); + } + return true; + } + if (holdInit && std::getenv("DEMO_TOWN_INTERIOR_HOLD")) { + const int step = frame - holdFrame; + if (step > 0 && step % 120 == 0 && + holdShots < 6) { + Ogre::String shot = "/tmp/townint_" + + std::to_string(holdShots) + + ".png"; + Ogre::RenderWindow *rw = + app->getRenderWindow(); + if (rw) + rw->writeContentsToFile(shot); + std::cout << "[test] town interior: wrote " + << shot << std::endl; + ++holdShots; + if (app->getPlayerControllerSystem()) + app->getPlayerControllerSystem() + ->setControllerYawPitch( + 90.0f * + (float)holdShots, + 30.0f); + } + return true; + } + if (frame > deadline) + return fail("timeout in phase " + + std::to_string(phase)); + + switch (phase) { + case 0: { + if (!clockSet) { + if (GameClockSystem *clock = + GameClockSystem::getInstance()) { + clock->setPaused(true); + clock->setTime(12, 12.0f); + } + clockSet = true; + } + /* Wait for a streamed multi-storey generated building + * with an enterable external door AND at least one + * scheduled NPC inside right now. */ + flecs::entity building; + std::string key; + std::string path; + app->getWorld()->each( + [&](flecs::entity e, CellGridComponent &g) { + if (building.is_alive()) + return; + if (g.generationMode != + "exteriorOnly" || + g.height < 2 || + g.gridUid.rfind("town-", 0) != 0) + return; + if (g.cells.empty()) + return; + if (eligibleInBuilding( + g.gridUid.substr(5)) == 0) + return; + for (const auto &dc : g.doorConfigs) { + if (dc.second.sceneSwitchPath + .rfind("generated:", 0) == + 0) { + key = dc.first; + path = dc.second + .sceneSwitchPath; + building = e; + break; + } + } + }); + if (!building.is_alive()) { + if (++wait > 12000) + return fail("no multi-storey generated " + "building appeared"); + return true; + } + const CellGridComponent &g = + building.get(); + gridUid = g.gridUid; + doorKey = key; + scheme = path; + multiStorey = g.height > 1; + expectedCells = (int)g.cells.size(); + expectedStairs = 0; + for (const Cell &cell : g.cells) + if (cell.flags & CellFlags::StairWell) + ++expectedStairs; + expectedNpcs = eligibleInBuilding( + g.gridUid.substr(5)); + std::cout << "[test] town interior: entering " + << scheme << " door " << doorKey + << " (" << expectedCells << " cells, " + << g.height << " floors)" << std::endl; + if (std::getenv("DEMO_ROAD_MAT_LOG")) + if (std::getenv("DEMO_ROAD_MAT_LOG")) + logRoadMaterials("before"); + SceneSwitchOptions opts; + opts.targetEntityName = "arrival:" + doorKey; + if (!app->switchScene(scheme, opts)) + return fail("switchScene(scheme) refused"); + wait = 0; + phase = 1; + return true; + } + case 1: { + if (!app->isInsideGeneratedInterior()) + return true; + flecs::entity interior; + app->getWorld()->each( + [&](flecs::entity e, CellGridComponent &g) { + if (g.gridUid == gridUid && + g.generationMode == + "interiorOnly") + interior = e; + }); + if (!interior.is_alive()) + return true; /* still loading */ + const CellGridComponent &g = + interior.get(); + if ((int)g.cells.size() != expectedCells) + return fail("interior cell count " + + std::to_string( + (int)g.cells.size()) + + " != " + + std::to_string(expectedCells)); + int stairs = 0; + for (const Cell &cell : g.cells) + if (cell.flags & CellFlags::StairWell) + ++stairs; + if (stairs != expectedStairs) + return fail("interior stairwell cells " + + std::to_string(stairs) + " != " + + std::to_string(expectedStairs)); + if (multiStorey && stairs == 0) + return fail("multi-storey interior has no " + "stairwell"); + /* Phase 8.3: multi-floor interiors carry the SAME + * stairwell room on every floor; without the upper + * instance the flight tops out inside another room + * (the old carve). */ + if (g.height > 1) { + const RoomComponent *stairLo = nullptr; + const RoomComponent *stairUp = nullptr; + int stairFloors = 0; + interior.children([&](flecs::entity c) { + if (!c.has()) + return true; + const auto &r = + c.get(); + if (r.roomType != "stairwell") + return true; + ++stairFloors; + if (r.minY == 0) + stairLo = &r; + if (r.minY == 1) + stairUp = &r; + return true; + }); + if (stairFloors > 0) { + if (!stairLo || !stairUp) + return fail("stairwell room " + "is not stacked " + "on every floor"); + if (stairLo->minX != + stairUp->minX || + stairLo->minZ != + stairUp->minZ || + stairLo->maxX != + stairUp->maxX || + stairLo->maxZ != + stairUp->maxZ) + return fail("stairwell room " + "rects differ " + "between floors"); + bool upperDoor = false; + for (const Cell &c : g.cells) + if (c.y == 1 && + c.x >= stairUp->minX && + c.x < stairUp->maxX && + c.z >= stairUp->minZ && + c.z < stairUp->maxZ && + (c.flags & + (CellFlags:: + IntDoorXNeg | + CellFlags:: + IntDoorXPos | + CellFlags:: + IntDoorZPos | + CellFlags:: + IntDoorZNeg))) + upperDoor = true; + if (!upperDoor) + return fail("upper stairwell " + "room has no " + "interior door"); + } + } + int rooms = 0; + interior.children([&](flecs::entity child) { + if (child.has()) + ++rooms; + return true; + }); + if (rooms == 0) + return fail("interior has no room metadata"); + if (g.furnitureCells.empty()) { + if (++wait < 600) + return true; /* furnishing runs */ + return fail("interior rooms were not " + "furnished"); + } + { + InteriorNpcSystem *ins = + app->getInteriorNpcSystem(); + if (!ins) + return fail("no InteriorNpcSystem"); + if ((int)ins->eligibleCount() != expectedNpcs) + return fail("interior NPC eligibility changed"); + const int want = std::min( + expectedNpcs, 12); + if ((int)ins->spawnedCount() != want) + return fail("interior NPC spawn count " + + std::to_string( + (int)ins->spawnedCount()) + + " != " + + std::to_string(want)); + int tagged = 0; + app->getWorld()->each( + [&](flecs::entity e, + CharacterIdentityComponent &) { + if (e.has()) + ++tagged; + }); + if (tagged != want) + return fail("interior NPC entities " + + std::to_string(tagged) + + " != " + + std::to_string(want)); + std::cout << "[test] town interior: " + << tagged + << " scheduled NPC(s) inside" + << std::endl; + } + if (std::getenv("DEMO_INTERIOR_COLLIDERS")) { + /* Debug: dump every static body AABB so ghost + * colliders can be matched against the visible + * meshes. */ + JoltPhysicsWrapper *phys = + JoltPhysicsWrapper::getSingletonPtr(); + if (phys && phys->getPhysicsSystem()) { + JPH::BodyIDVector ids; + phys->getPhysicsSystem() + ->GetBodies(ids); + std::cout << "[colliders] " + << ids.size() + << " bodies" << std::endl; + for (const JPH::BodyID &id : ids) { + JPH::TransformedShape ts = + phys->getPhysicsSystem() + ->GetBodyInterface() + .GetTransformedShape( + id); + JPH::AABox bb = + ts.GetWorldSpaceBounds(); + /* Interior-only: the generated building + * sits near the origin. */ + if (bb.mMax.GetX() < -20.0f || + bb.mMin.GetX() > 40.0f || + bb.mMax.GetZ() < -20.0f || + bb.mMin.GetZ() > 40.0f) + continue; + const JPH::Shape *shape = + phys->getPhysicsSystem() + ->GetBodyInterface() + .GetShape(id); + const char *type = "?"; + switch (shape ? shape->GetType() : + JPH::EShapeType::User1) { + case JPH::EShapeType::Convex: + type = "convex"; + break; + case JPH::EShapeType::Compound: + type = "compound"; + break; + case JPH::EShapeType::Decorated: + type = "decorated"; + break; + case JPH::EShapeType::Mesh: + type = "mesh"; + break; + case JPH::EShapeType::HeightField: + type = "heightfield"; + break; + case JPH::EShapeType::User1: + type = "user1"; + break; + default: + type = "other"; + break; + } + std::cout << "[colliders] body " + << id.GetIndex() + << " layer " + << (int)phys + ->getPhysicsSystem() + ->GetBodyInterface() + .GetObjectLayer(id) + << " " << type + << " min (" + << bb.mMin.GetX() << ", " + << bb.mMin.GetY() << ", " + << bb.mMin.GetZ() << ") max (" + << bb.mMax.GetX() << ", " + << bb.mMax.GetY() << ", " + << bb.mMax.GetZ() << ")" + << std::endl; + if (shape && shape->GetType() == + JPH::EShapeType:: + Compound) { + const auto *compound = + static_cast( + shape); + std::cout << "[colliders] " + << compound + ->GetNumSubShapes() + << " sub-shapes" + << std::endl; + for (JPH::uint si = 0; + si < compound + ->GetNumSubShapes(); + ++si) { + const auto &sub = + compound + ->GetSubShape( + si); + const JPH::AABox sb = + sub.mShape + ->GetLocalBounds() + .Transformed( + JPH::Mat44:: + sRotationTranslation( + sub.GetRotation(), + sub.GetPositionCOM())); + std::cout << "[colliders] sub " + << si << " (" + << sb.mMin.GetX() + << ", " + << sb.mMin.GetY() + << ", " + << sb.mMin.GetZ() + << ")-(" + << sb.mMax.GetX() + << ", " + << sb.mMax.GetY() + << ", " + << sb.mMax.GetZ() + << ")" + << std::endl; + } + } + } + } + app->getRoot()->queueEndRendering(); + return true; + } + { + const auto colliders = + app->getCellGridSystem() + ->getFurniturePlacements( + interior); + std::cout << "[test] town interior: " + << colliders.size() + << " furniture colliders" << std::endl; + for (const FurnitureCell &fc : + g.furnitureCells) + std::cout << "[test] town interior: " + << "furniture " << fc.furnitureType + << " at " << fc.x << "," + << fc.y << "," << fc.z << " (" + << fc.owner << ")" + << std::endl; + for (const auto &fp : + app->getCellGridSystem() + ->getFramePlacements(interior)) + std::cout << "[test] town interior: " + << "frame " << fp.meshName + << " at (" << fp.position.x + << ", " << fp.position.y + << ", " << fp.position.z + << ")" << std::endl; + } + const auto it = g.doorConfigs.find(doorKey); + if (it == g.doorConfigs.end() || + !it->second.hasSceneSwitchPose || + it->second.sceneSwitchPath != + app->getCurrentBaseScene()) + return fail("interior exit door is not " + "pre-wired"); + exitPos = Ogre::Vector3(it->second.sceneSwitchPosX, + it->second.sceneSwitchPosY, + it->second.sceneSwitchPosZ); + exitYaw = it->second.sceneSwitchYawDeg; + std::cout << "[test] town interior: inside, " + << rooms << " rooms, " << stairs + << " stair cells, " + << g.furnitureCells.size() + << " furniture placements" << std::endl; + const char *holdEnv = + std::getenv("DEMO_TOWN_INTERIOR_HOLD"); + if (holdEnv && holdEnv[0] == 's') { + const RoomComponent *stairLo = nullptr; + const RoomComponent *stairUp = nullptr; + interior.children([&](flecs::entity c) { + if (!c.has()) + return true; + const auto &r = + c.get(); + if (r.roomType != "stairwell") + return true; + if (r.minY == 0) + stairLo = &r; + if (r.minY == 1) + stairUp = &r; + return true; + }); + if (stairLo && stairUp) { + const LayoutLib::StairRun run = + LayoutLib::planStairRun( + stairLo->minX, + stairLo->minZ, + stairLo->maxX - 1, + stairLo->maxZ - 1); + const float cs = g.cellSize; + const float chh = g.cellHeight; + const float fy = + CellGridSystem:: + floorSurfaceY(); + const int runLen = + run.valid ? + (run.alongX ? + run.x1 - + run.x0 + + 1 : + run.z1 - + run.z0 + + 1) : + 1; + /* Boarding: just inside the low + * end, facing along the run. */ + Ogre::Vector3 board( + (float)stairLo->minX * cs, + fy + 0.3f, + (float)stairLo->minZ * cs); + Ogre::Vector3 top( + (float)stairLo->minX * cs, + chh + fy + 0.3f, + (float)stairLo->minZ * cs); + float yaw = 0.0f; + Ogre::Vector3 along(1, 0, 0); + if (run.valid) { + if (run.alongX) { + board.x = + (float)run.x0 * + cs - + 0.8f; + top.x = + (float)run.x1 * + cs + + 0.8f; + along = Ogre::Vector3( + 1, 0, 0); + } else { + board.z = + (float)run.z0 * + cs - + 0.8f; + top.z = + (float)run.z1 * + cs + + 0.8f; + along = Ogre::Vector3( + 0, 0, 1); + } + top.y = chh + fy + 0.3f; + } + yaw = Ogre::Math::ATan2( + -along.x, -along.z) + .valueDegrees(); + holdStairPos[0] = board; + holdStairYaw[0] = yaw; + holdStairPos[1] = top; + holdStairYaw[1] = yaw; + holdStairStage = 0; + holdInit = true; + holdFrame = frame; + std::cout << "[test] town interior: " + << "stair run " << runLen + << " cells; boarding " + << board << ", top " + << top << std::endl; + return true; + } + } + if (holdEnv && holdEnv[0] == 'f') { + holdFurn.assign(g.furnitureCells.begin(), + g.furnitureCells.end()); + holdFurnIdx = 0; + holdInit = true; + holdFrame = frame; + return true; + } + if (holdEnv) { + /* Debug visual inspection: stand in a room that + * has furniture and look around. */ + flecs::entity player = + app->getPlayerCharacterEntity(); + if (player.is_alive() && + player.has()) { + /* Prefer an interior NPC: stand two metres + * in front of it so the screenshot shows + * the scheduled character. */ + flecs::entity npc; + app->getWorld()->each( + [&](flecs::entity e, + CharacterIdentityComponent &) { + if (!npc.is_alive() && + e.has()) + npc = e; + }); + const FurnitureCell *fc = + g.furnitureCells.empty() ? + nullptr : + &g.furnitureCells.front(); + const RoomComponent *best = nullptr; + const RoomComponent *noStair = nullptr; + interior.children([&](flecs::entity c) { + if (!c.has()) + return true; + const auto &r = + c.get(); + if (fc && !best && + r.contains(fc->x, fc->y, + fc->z)) + best = &r; + bool hasStair = false; + for (const Cell &sc : g.cells) + if ((sc.flags & + CellFlags::StairWell) && + sc.x >= r.minX && + sc.x < r.maxX && + sc.y >= r.minY && + sc.y < r.maxY && + sc.z >= r.minZ && + sc.z < r.maxZ) + hasStair = true; + if (!hasStair && + (!noStair || + r.minY < noStair->minY)) + noStair = &r; + return true; + }); + if (!std::getenv( + "DEMO_TOWN_INTERIOR_KEEP_NPC_ROOM")) { + if (noStair) + best = noStair; + } + if (!best) { + int bestArea = -1; + interior.children( + [&](flecs::entity c) { + if (!c.has< + RoomComponent>()) + return true; + const auto &r = c.get< + RoomComponent>(); + int area = r.getSizeX() * + r.getSizeZ(); + if (area > bestArea) { + bestArea = area; + best = &r; + } + return true; + }); + } + auto &pt = player.get_mut< + TransformComponent>(); + Ogre::Vector3 p = + Ogre::Vector3::ZERO; + if (std::getenv( + "DEMO_TOWN_INTERIOR_KEEP_NPC_ROOM") && + npc.is_alive() && + npc.has()) { + p = npc.get() + .position + + Ogre::Vector3(0, 0, 2.0f); + } else if (best) { + float cx = (best->minX + + best->maxX) * + 0.5f; + float cz = (best->minZ + + best->maxZ) * + 0.5f; + p = Ogre::Vector3( + cx * g.cellSize, + (float)best->minY * + g.cellHeight + + CellGridSystem:: + floorSurfaceY() + + 0.2f, + cz * g.cellSize); + } + pt.position = p; + pt.rotation = + Ogre::Quaternion::IDENTITY; + pt.applyToNode(); + if (player.has()) + player.get_mut< + CharacterComponent>() + .linearVelocity = + Ogre::Vector3::ZERO; + /* Keep the TPS boom inside the room so the + * hold screenshots show the actual + * interior instead of the void beyond + * the walls. */ + app->getWorld()->each( + [&](flecs::entity, + PlayerControllerComponent & + pc) { + pc.tpsDistance = 1.2f; + pc.tpsHeight = 1.4f; + }); + if (app->getPlayerControllerSystem()) + app->getPlayerControllerSystem() + ->setControllerYawPitch( + 0.0f, 0.0f); + } + holdInit = true; + holdFrame = frame; + return true; + } + ++visits; + const ColliderCensus census = dumpCensus("inside"); + if (visits == 1) { + firstCensus = census; + } else if (census.bodies != firstCensus.bodies || + census.subShapes != + firstCensus.subShapes) { + return fail("static collider census grew on " + "revisit: " + + std::to_string(census.bodies) + + " bodies/" + + std::to_string( + census.subShapes) + + " sub-shapes vs " + + std::to_string( + firstCensus.bodies) + + "/" + + std::to_string( + firstCensus.subShapes)); + } + if (visits < maxVisits) { + SceneSwitchOptions back; + back.hasPosition = true; + back.posX = exitPos.x; + back.posY = exitPos.y; + back.posZ = exitPos.z; + back.hasRotation = true; + back.rotation = Ogre::Quaternion( + Ogre::Degree(exitYaw), + Ogre::Vector3::UNIT_Y); + if (!app->switchScene( + it->second.sceneSwitchPath, + back)) + return fail("revisit exit refused"); + wait = 0; + phase = 10; + return true; + } + SceneSwitchOptions back; + back.hasPosition = true; + back.posX = exitPos.x; + back.posY = exitPos.y; + back.posZ = exitPos.z; + back.hasRotation = true; + back.rotation = Ogre::Quaternion( + Ogre::Degree(exitYaw), Ogre::Vector3::UNIT_Y); + if (!app->switchScene(it->second.sceneSwitchPath, + back)) + return fail("exit switch refused"); + wait = 0; + phase = 2; + return true; + } + case 10: { + if (app->isInsideGeneratedInterior() || + app->hasPendingSceneSwitch()) + return true; + if (wait == 30) { + dumpCensus("outside"); + if (std::getenv("DEMO_ROAD_MAT_LOG")) + logRoadMaterials("after"); + } + /* The exterior scene streams back in: wait until the + * same building (and its plan registration) is up + * before resolving the generated scheme again. */ + bool ready = false; + app->getWorld()->each( + [&](flecs::entity, CellGridComponent &g) { + if (g.gridUid != gridUid || + g.generationMode != + "exteriorOnly") + return; + for (const auto &dc : g.doorConfigs) + if (dc.first == doorKey && + dc.second.sceneSwitchPath == + scheme) + ready = true; + }); + if (!ready) { + if (++wait > 12000) + return fail("building did not come " + "back for the revisit"); + return true; + } + SceneSwitchOptions opts; + opts.targetEntityName = "arrival:" + doorKey; + if (!app->switchScene(scheme, opts)) + return fail("revisit entry refused"); + wait = 0; + phase = 1; + return true; + } + case 2: { + if (app->isInsideGeneratedInterior()) + return true; + if (std::getenv("DEMO_TOWN_POST_EXIT_SHOT") && + !postExitShot) { + /* Let the loading cover finish and the TPS + * camera settle before the shot. */ + if (wait < 90) { + if (wait == 1 && + app->getPlayerControllerSystem()) + app->getPlayerControllerSystem() + ->setControllerYawPitch( + exitYaw + + 180.0f, + -25.0f); + ++wait; + return true; + } + postExitShot = true; + std::cout << "[test] town interior: exit pose " + << exitPos << " yaw " << exitYaw + << std::endl; + logRoadMaterials("postexit"); + { + Ogre::TextureManager &tm = + Ogre::TextureManager:: + getSingleton(); + Ogre::TexturePtr t = + tm.getByName( + "RoadAtlasTexture"); + if (!t.isNull()) + std::cout + << "[test] RoadAtlas " + "w=" + << t->getWidth() + << " h=" + << t->getHeight() + << " fmt=" + << (int)t->getFormat() + << std::endl; + else + std::cout << "[test] " + "RoadAtlasTexture " + "missing" + << std::endl; + } + if (Ogre::RenderWindow *rw = + app->getRenderWindow()) + rw->writeContentsToFile( + "/tmp/postexit.png"); + std::cout << "[test] town interior: wrote " + "/tmp/postexit.png" + << std::endl; + app->getRoot()->queueEndRendering(); + return true; + } + flecs::entity player = + app->getPlayerCharacterEntity(); + if (!player.is_alive() || + !player.has()) + return true; + const Ogre::Vector3 pp = worldPos(player); + if ((pp - exitPos).length() > 2.0f) + return fail("player did not return to the " + "pre-wired approach pose"); + std::cout << "[test] PASS: generated town interior is " + "complete (rooms, stacked stairwell, " + "furniture, interior NPCs), two visits " + "keep an identical static collider set " + "(no ghost colliders), and it exits at " + "the pre-wired pose" + << std::endl; + phase = 3; + app->getRoot()->queueEndRendering(); + return true; + } + } + return true; + } +}; + +/* + * --test-fall (phase 8.4): gravity + step-up acceptance. + * + * Builds an isolated static floor pad, drops a bare CharacterSystem + * capsule from 8 m (measures the vertical acceleration and that it does + * not tunnel through the floor), then walks it into a 0.35 m static + * ledge (the controller must lift it onto the ledge) and a 1.2 m box + * (must NOT be climbed). Guards the "slow falling" and "characters + * can't climb onto the road" regressions. + * + * Run: ./demoTaxi --headless --test-fall + */ +struct FallTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + const Ogre::Vector3 pad = Ogre::Vector3(200, 0, 200); + flecs::entity walker; + Ogre::SceneNode *walkerNode = nullptr; + flecs::entity floorEntity; + flecs::entity box; + float elapsed = 0.0f; + float phaseTime = 0.0f; + bool haveSample = false; + float ySample[3] = { 0, 0, 0 }; + float tSample[4] = { 0, 0.3f, 0.6f, 0.9f }; + int nextSample = 1; + int deadline = 60000; + + explicit FallTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const std::string &why) + { + if (failed) + return true; + failed = true; + failReason = why; + std::cerr << "[test] fall FAIL: " << why << std::endl; + app->getRoot()->queueEndRendering(); + return true; + } + + Ogre::Vector3 walkerPos() const + { + if (!walkerNode) + return pad; + return walkerNode->_getDerivedPosition(); + } + + flecs::entity buildWalker(const char *name, const Ogre::Vector3 &pos, + Ogre::SceneNode *&outNode) + { + flecs::world *w = app->getWorld(); + Ogre::SceneNode *node = app->getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(name); + node->setPosition(pos); + outNode = node; + flecs::entity e = w->entity(); + TransformComponent t; + t.node = node; + t.position = pos; + t.rotation = Ogre::Quaternion::IDENTITY; + t.scale = Ogre::Vector3::UNIT_SCALE; + e.set(t); + CharacterComponent cc; + cc.dirty = true; + e.set(cc); + return e; + } + + flecs::entity spawnBox(const Ogre::Vector3 ¢er, float halfY, + const Ogre::String &name) + { + flecs::world *w = app->getWorld(); + flecs::entity e = w->entity(); + Ogre::SceneNode *n = + app->getSceneManager()->getRootSceneNode() + ->createChildSceneNode(name); + n->setPosition(center); + TransformComponent t; + t.node = n; + t.position = center; + t.rotation = Ogre::Quaternion::IDENTITY; + e.set(t); + e.set(EntityNameComponent(name)); + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + e.set(rb); + PhysicsColliderComponent col; + col.shapeType = PhysicsColliderComponent::ShapeType::Box; + col.parameters = Ogre::Vector3(3.0f, halfY, 3.0f); + e.set(col); + return e; + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + ++frame; + if (failed || phase == 9) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > deadline) + return fail("timeout in phase " + + std::to_string(phase)); + if (!editScene::isGamePlaying()) + return true; + /* Headless runs pin the simulation delta (setFixedDeltaTime); + * use the same clock the physics steps with so the measured + * acceleration is gravity, not wall time. */ + const float dt = app->getFixedDeltaTime() > 0.0f ? + app->getFixedDeltaTime() : + evt.timeSinceLastFrame; + + switch (phase) { + case 0: { + /* Isolated floor pad (top at y = 0) + walker. */ + floorEntity = spawnBox( + pad + Ogre::Vector3(0, -0.5f, 0), 0.5f, + "FallTestFloor"); + walker = buildWalker( + "FallTestWalker", + pad + Ogre::Vector3(0, 8, 0), walkerNode); + elapsed = 0.0f; + nextSample = 1; + haveSample = false; + phaseTime = 0.0f; + phase = 1; + return true; + } + case 1: { + /* Wait for the physics character to materialize. */ + if (!walker.is_alive() || + !app->getCharacterSystem() + ->hasPhysicsCharacter(walker)) + return true; + elapsed += dt; + const float y = walkerPos().y; + if (!haveSample) { + ySample[0] = y; + haveSample = true; + } else if (nextSample < 3 && + elapsed >= tSample[nextSample]) { + ySample[nextSample] = y; + ++nextSample; + } + if (nextSample < 3) + return true; + const float v1 = (ySample[1] - ySample[0]) / + (tSample[1] - tSample[0]); + const float v2 = (ySample[2] - ySample[1]) / + (tSample[2] - tSample[1]); + /* v1/v2 are interval averages; their midpoints are + * (t0+t1)/2 and (t1+t2)/2 → difference (t2-t0)/2. */ + const float accel = + (v2 - v1) / + ((tSample[2] - tSample[0]) * 0.5f); + std::cout << "[test] fall: accel " << accel + << " m/s^2 (y " << ySample[0] << " " + << ySample[1] << " " << ySample[2] << ")" + << std::endl; + const float g = 9.81f * 1.35f; /* gravityScale */ + const float mag = std::fabs(accel); + if (mag < g * 0.7f || mag > g * 1.3f) + return fail("vertical acceleration " + + std::to_string(accel) + + " m/s^2 is outside " + + std::to_string(g * 0.7f) + + ".." + + std::to_string(g * 1.3f)); + if (ySample[2] > ySample[0]) + return fail("character did not fall"); + phase = 2; + phaseTime = 0.0f; + return true; + } + case 2: { + phaseTime += dt; + const float y = walkerPos().y; + if (y < 0.35f) { + if (y < -0.6f) + return fail("character fell through " + "the floor (y " + + std::to_string(y) + ")"); + std::cout << "[test] fall: landed at y " + << y << " after " + << (elapsed + phaseTime) << " s" + << std::endl; + /* Reset for the step-up test. */ + if (box.is_alive()) + box.destruct(); + box = spawnBox( + pad + Ogre::Vector3(0, 0.175f - 0.02f, + 3.0f), + 0.175f, "FallTestLowLedge"); + if (walker.is_alive() && + walker.has()) { + auto &t = walker.get_mut< + TransformComponent>(); + t.position = pad + + Ogre::Vector3(0, 0.1f, 0); + t.applyToNode(); + walker.get_mut() + .linearVelocity = + Ogre::Vector3::ZERO; + } + phaseTime = 0.0f; + phase = 3; + } + return true; + } + case 3: { + /* Let the ledge's static body materialize, then + * walk into it. */ + phaseTime += dt; + if (phaseTime > 0.3f) { + if (walker.is_alive()) + walker.get_mut() + .linearVelocity = + Ogre::Vector3(0, 0, 1.5f); + phaseTime = 0.0f; + phase = 4; + } + return true; + } + case 4: { + phaseTime += dt; + const float y = walkerPos().y; + if (y >= 0.25f) { + std::cout << "[test] fall: stepped onto the " + "0.35 m ledge (y " + << y << ")" << std::endl; + if (walker.is_alive()) + walker.get_mut() + .linearVelocity = + Ogre::Vector3::ZERO; + if (box.is_alive()) + box.destruct(); + box = spawnBox( + pad + Ogre::Vector3(0, 0.6f - 0.02f, + 3.0f), + 0.6f, "FallTestTallBox"); + if (walker.is_alive() && + walker.has()) { + auto &t = walker.get_mut< + TransformComponent>(); + t.position = pad + + Ogre::Vector3(0, 0.1f, 0); + t.applyToNode(); + } + phaseTime = 0.0f; + phase = 5; + return true; + } + if (phaseTime > 4.0f) + return fail("character could not step onto " + "a 0.35 m static ledge (y " + + std::to_string(y) + ")"); + return true; + } + case 5: { + phaseTime += dt; + if (phaseTime > 0.3f) { + if (walker.is_alive()) + walker.get_mut() + .linearVelocity = + Ogre::Vector3(0, 0, 1.5f); + phaseTime = 0.0f; + phase = 6; + } + return true; + } + case 6: { + phaseTime += dt; + const float y = walkerPos().y; + if (y >= 0.5f) + return fail("character climbed a 1.2 m box " + "(y " + + std::to_string(y) + ")"); + if (phaseTime > 2.5f) { + if (walker.is_alive()) + walker.get_mut() + .linearVelocity = + Ogre::Vector3::ZERO; + if (box.is_alive()) + box.destruct(); + std::cout << "[test] PASS: fall acceleration " + "is gravity-like and the " + "controller steps onto 0.35 m " + "ledges without climbing 1.2 m " + "boxes" + << std::endl; + phase = 9; + app->getRoot()->queueEndRendering(); + } + return true; + } + } + return true; + } +}; + +/* + * --test-stolen-car (phase 8.2 follow-up): carjacked NPC car ownership. + * + * Picks a high-detail NPC traffic car, takes it with the real + * "vehicle_enter" path, and verifies: + * - the car leaves the fleet and is renamed/tagged as the player's + * (VehicleOwnershipComponent carries the owner id); + * - the owner walks for the rest of the theft day; + * - a save/load keeps the abandoned car at its last pose (the NPC + * state store) and keeps the owner carless on the same day; + * - advancing to the next in-game day assigns the owner a fresh car + * while the abandoned one stays where it was. + * + * Run: ./demoTaxi --headless --test-stolen-car + */ +struct StolenCarTestListener : public Ogre::FrameListener { + EditorApp *app; + int frame = 0; + int phase = 0; + bool failed = false; + Ogre::String failReason; + int deadline = 60000; + uint64_t driverId = 0; + std::string carName; + Ogre::Vector3 carPos = Ogre::Vector3::ZERO; + int wait = 0; + bool clockSet = false; + const std::string savePath = + "/tmp/opencode/demo_taxi_stolen_car.json"; + + explicit StolenCarTestListener(EditorApp *a) + : app(a) + { + } + + bool fail(const std::string &why) + { + if (failed) + return true; + failed = true; + failReason = why; + std::cerr << "[test] stolen car FAIL: " << why << std::endl; + app->getRoot()->queueEndRendering(); + return true; + } + + flecs::entity findByName(const std::string &name) + { + flecs::entity found; + app->getWorld()->each( + [&](flecs::entity e, EntityNameComponent &n) { + if (!found.is_alive() && + e.has() && + n.name == name) + found = e; + }); + return found; + } + + flecs::entity findStolenCar() + { + const std::string prefix = + "stolen_" + std::to_string((unsigned long long)driverId) + + "_"; + flecs::entity found; + app->getWorld()->each( + [&](flecs::entity e, EntityNameComponent &n) { + if (!found.is_alive() && + e.has() && + n.name.rfind(prefix, 0) == 0) + found = e; + }); + return found; + } + + Ogre::Vector3 worldPos(flecs::entity e) + { + if (!e.is_alive() || !e.has()) + return Ogre::Vector3::ZERO; + const auto &t = e.get(); + const Ogre::Vector3 rp = + t.node ? t.node->_getDerivedPosition() : t.position; + JPH::DVec3 wp(rp.x, rp.y, rp.z); + if (RenderOriginSystem *ro = + RenderOriginSystem::getInstance()) + wp = ro->renderToWorld(rp); + return Ogre::Vector3((float)wp.GetX(), (float)wp.GetY(), + (float)wp.GetZ()); + } + + static bool hasDriver(const std::vector &ids, + uint64_t driver) + { + return std::find(ids.begin(), ids.end(), driver) != ids.end(); + } + + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + ++frame; + if (failed || phase == 5) { + app->getRoot()->queueEndRendering(); + return true; + } + if (frame > deadline) + return fail("timeout in phase " + + std::to_string(phase)); + + NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance(); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + VehicleControllerSystem *vcs = + app->getVehicleControllerSystem(); + GameClockSystem *clock = GameClockSystem::getInstance(); + if (!traffic || !pop || !vcs || !clock) + return fail("missing traffic/population/controller/" + "clock system"); + + switch (phase) { + case 0: { + const std::vector ids = + pop->populationCharacterIds(); + if (ids.empty()) + return true; + /* A fixed day makes the theft day / replacement day + * assertions deterministic. */ + if (!clockSet) { + clock->setPaused(true); + clock->setTime(7, 12.0f); + traffic->spawnDistanceSq = 1.0e8f; + traffic->despawnDistanceSq = 4.0e8f; + traffic->spawnBudgetPerFrame = 16; + traffic->maxActiveVehicles = 64; + clockSet = true; + return true; + } + if (traffic->carCount() == 0) + return true; + /* Wait for a high-detail fleet car to exist. */ + for (uint64_t id : traffic->carDriverIds()) { + const std::string name = + "traffic_" + + std::to_string((unsigned long long)id); + flecs::entity car = findByName(name); + if (car.is_alive()) { + driverId = id; + carName = name; + break; + } + } + if (driverId == 0) { + if (++wait > 1800) + return fail("no high-detail traffic car " + "appeared"); + return true; + } + std::cout << "[test] stolen car: taking '" << carName + << "' (driver " << driverId << ")" + << std::endl; + editScene::EventParams enter; + enter.setString("vehicle", carName); + EventBus::getInstance().send("vehicle_enter", enter); + wait = 0; + phase = 1; + return true; + } + case 1: { + if (!vcs->getDrivenVehicle().is_alive()) { + if (++wait > 600) + return fail("vehicle enter did not " + "seat the player"); + return true; + } + if (!traffic->isDriverStolen(driverId)) { + if (++wait > 600) + return fail("carjack was not " + "recognised"); + return true; + } + flecs::entity car = findStolenCar(); + if (!car.is_alive()) + return true; + if (!car.has() || + car.get().ownerId != + driverId) + return fail("abandoned car lost its owner tag"); + if (hasDriver(traffic->carDriverIds(), driverId)) + return fail("carjacked driver still has a " + "fleet car"); + if (traffic->isDriving(driverId)) + return fail("carjacked owner is still driving"); + carName = car.get().name; + vcs->exitVehicle(); + wait = 0; + phase = 2; + return true; + } + case 2: { + if (++wait < 30) + return true; /* let the car settle */ + flecs::entity car = findByName(carName); + if (!car.is_alive()) + return fail("abandoned car disappeared after " + "the carjack"); + /* Park it exactly where it is: the store must bring + * the same pose back after a load. */ + if (car.has()) { + auto &vc = car.get_mut(); + vc.inputHandBrake = 1.0f; + vc.inputForward = 0.0f; + vc.inputRight = 0.0f; + } + if (car.has() && + app->getEditorPhysicsSystem()) { + auto &rb = car.get(); + JoltPhysicsWrapper *phys = + app->getEditorPhysicsSystem() + ->getPhysicsWrapper(); + if (rb.bodyCreated && phys) + phys->setVelocities( + rb.bodyID, + Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } + carPos = worldPos(car); + app->saveGame(savePath, "stolen-probe"); + { + std::ifstream in(savePath); + nlohmann::json saved; + in >> saved; + const auto &sv = saved["stolenVehicles"]; + if (!sv.is_array() || sv.size() != 1 || + sv[0].value("driver", 0ULL) != driverId) + return fail("save file lost the stolen " + "ledger"); + bool carRecord = false; + const auto &recs = + saved["vehicleState"]["records"]; + for (const auto &r : recs) + if (r.value("owner", 0ULL) == driverId && + r.value("name", std::string()) == + carName) + carRecord = true; + if (!carRecord) + return fail("save file lost the " + "abandoned car pose"); + } + std::cout << "[test] stolen car: saved, reloading" + << std::endl; + app->loadGame(savePath); + wait = 0; + phase = 3; + return true; + } + case 3: { + /* The load restores the ledger before the scene; the + * abandoned runtime car is re-created from the + * vehicle store. */ + if (traffic->stolenCarCount() != 1) { + if (++wait > 120) + return fail("load lost the stolen " + "ledger"); + return true; + } + flecs::entity car = findByName(carName); + if (!car.is_alive()) { + if (++wait > 2400) + return fail("load did not re-create the " + "abandoned car"); + return true; + } + if ((worldPos(car) - carPos).length() > 2.0f) + return fail("abandoned car pose was reset by " + "the load"); + if (traffic->carCount() == 0) { + if (++wait > 12000) + return fail("fleet did not rebuild " + "after the load"); + return true; + } + if (hasDriver(traffic->carDriverIds(), driverId)) + return fail("owner got a car on the theft " + "day after the load"); + std::cout << "[test] stolen car: restored at (" + << carPos.x << ", " << carPos.y << ", " + << carPos.z << "), owner walking" + << std::endl; + clock->setTime(clock->getDay() + 1, + clock->getTimeOfDayHours()); + wait = 0; + phase = 4; + return true; + } + case 4: { + if (traffic->stolenCarCount() != 0) { + if (++wait > 1800) + return fail("stolen marker was not " + "cleared on the next " + "day"); + return true; + } + if (!hasDriver(traffic->carDriverIds(), driverId)) { + if (++wait > 1800) + return fail("owner did not get a " + "replacement car"); + return true; + } + flecs::entity car = findByName(carName); + if (!car.is_alive()) + return fail("abandoned car disappeared on " + "the replacement day"); + if ((worldPos(car) - carPos).length() > 2.0f) + return fail("abandoned car moved on the " + "replacement day"); + std::cout << "[test] PASS: carjacked NPC car kept its " + "pose; the owner walked today and got a " + "new car the next in-game day" + << std::endl; + phase = 5; + app->getRoot()->queueEndRendering(); + return true; + } + } + return true; + } +}; + /* * --test-interior (phase 8.2): in-memory generated-scene switching. * @@ -5316,6 +7483,68 @@ struct InteriorTestListener : public Ogre::FrameListener { Ogre::Vector3 exitPos = Ogre::Vector3::ZERO; float exitYaw = 0.0f; + int interiorFurnitureWait = 0; + + /* Debug: log road mesh material binding (asphalt texture lives in + * the road material; a scene switch used to drop it). */ + void logRoadMaterials(const char *tag) + { + int meshes = 0; + app->getWorld()->each( + [&](flecs::entity, RenderableComponent &r) { + if (r.meshName.rfind("Road_", 0) != 0) + return; + Ogre::MeshPtr mesh = + Ogre::MeshManager::getSingleton() + .getByName(r.meshName); + if (mesh.isNull()) + return; + ++meshes; + for (unsigned si = 0; + si < mesh->getNumSubMeshes(); ++si) { + const Ogre::String &mn = + mesh->getSubMesh(si) + ->getMaterialName(); + bool exists = + Ogre::MaterialManager:: + getSingleton() + .resourceExists(mn); + size_t tex = 0; + Ogre::String texName; + if (exists) { + Ogre::MaterialPtr m = + Ogre::MaterialManager:: + getSingleton() + .getByName(mn); + if (!m.isNull() && + m->getNumTechniques() > 0) { + Ogre::Technique *t = + m->getTechnique(0); + if (t->getNumPasses() > 0) { + Ogre::Pass *ps = + t->getPass(0); + tex = ps->getNumTextureUnitStates(); + if (tex) + texName = ps->getTextureUnitState(0)->getTextureName(); + } + } + } + std::cout << "[test] road mat " << tag + << ": " << mn << " exists=" + << exists << " tex=" << tex + << " " << texName + << std::endl; + } + }); + std::cout << "[test] road meshes " << tag << ": " << meshes + << std::endl; + } + /* Player-touched car probe: pose kept across the interior + * round trip and the save/load re-entry. */ + uint64_t carEntity = 0; + Ogre::Vector3 carExpected = Ogre::Vector3::ZERO; + int carEnterWait = 0; + bool carTouched = false; InteriorTestListener(EditorApp *a, const Ogre::String &scene) : app(a) @@ -5364,16 +7593,83 @@ struct InteriorTestListener : public Ogre::FrameListener { std::mt19937 rng(7); LayoutLib::LayoutEval eval(lb, &rng, nullptr); std::string err; + /* Phase 8.3: full-width stairwell strip + stacked + * room instance on the upper floor. */ if (!eval.evaluate( "(shell 12 8 :floors 2 :empty)" - "(floor 0 (room hall (rect 0 0 1 1))" + "(floor 0 (room stair (rect 0 0 1 0.5)" + " :type stairwell)" + " (room livingroom" + " (rect 0 0.5 0.5 1))" + " (room bedroom" + " (rect 0.5 0.5 1 1))" " (door front center))" - "(floor 1 (room up (rect 0 0 1 1))" + "(floor 1 (room livingroom (rect 0 0 1 1))" " (windows all))", plan, &err)) { fail("layout eval failed: " + err); return true; } + /* The registered interior plan must carry the + * automatic stairwell carved into the generated + * exterior grid (fillBuildingCells). */ + { + EditorPhysicsSystem *phys = + app->getEditorPhysicsSystem(); + if (!phys) { + fail("no physics for the stairwell plan " + "probe"); + return true; + } + TownGenSystem tgs(*app->getWorld(), + app->getSceneManager(), + phys->getPhysicsWrapper()); + TownGenSystem::loadLayoutLibrary(); + std::string perr; + if (!tgs.previewLayout( + "(shell 12 8 :floors 2)" + "(floor 0 (door front center))", + nullptr, 0, 42, + Ogre::Vector3(0, 0, 0), + "exteriorOnly", &perr)) { + fail("stairwell plan preview failed: " + + perr); + return true; + } + flecs::entity preview; + app->getWorld()->each( + [&](flecs::entity e, + CellGridComponent &) { + if (!preview.is_alive() || + e.id() > preview.id()) + preview = e; + }); + const LayoutLib::BuildingPlan *p = + preview.is_alive() ? + TownGenSystem::findBuildingPlan( + preview + .get() + .gridUid) : + nullptr; + int stairCells = 0; + if (p) + for (int y = 0; y < p->floors; ++y) + for (int z = 0; z < p->cellsZ; + ++z) + for (int x = 0; + x < p->cellsX; + ++x) + if (p->at(x, y, z) & + LayoutLib::PlanFlags:: + StairWell) + ++stairCells; + tgs.clearLayoutPreview(); + if (!p || stairCells == 0) { + fail("registered interior plan lost " + "the automatic stairwell"); + return true; + } + } TownGenSystem::registerBuildingPlan(uid, plan); flecs::world *w = app->getWorld(); @@ -5444,6 +7740,78 @@ struct InteriorTestListener : public Ogre::FrameListener { arrivalName = "arrival:" + doorKey; building.set(grid); + /* Phase 8.2 follow-up: take the scene's sedan for a + * short "drive" so it is player-touched, then leave + * it at a custom pose. The interior round trip must + * preserve that pose (VehicleStateStore). */ + { + flecs::entity sedan; + for (const char *name : + { "sedan1", "sedan_taxi" }) { + app->getWorld()->each( + [&](flecs::entity e, + EntityNameComponent &n) { + if (e.has() && + n.name == name) + sedan = e; + }); + if (sedan.is_alive()) + break; + } + if (!sedan.is_alive()) { + fail("no sedan for the vehicle state " + "probe"); + return true; + } + carEntity = sedan.id(); + const Ogre::Vector3 world( + origin.x + 25.0f, 0.6f, + origin.z + 25.0f); + Ogre::Vector3 render = world; + if (RenderOriginSystem *ro = + RenderOriginSystem::getInstance()) + render = ro->worldToRender( + world.x, world.y, world.z); + auto &ct = sedan.get_mut(); + ct.position = render; + ct.rotation = Ogre::Quaternion::IDENTITY; + ct.worldX = world.x; + ct.worldY = world.y; + ct.worldZ = world.z; + ct.hasWorldPosition = true; + ct.applyToNode(); + if (sedan.has()) { + auto &rb = + sedan.get_mut(); + EditorPhysicsSystem *phys = + app->getEditorPhysicsSystem(); + if (rb.bodyCreated && phys) { + phys->getPhysicsWrapper() + ->setPosition( + rb.bodyID, + JPH::RVec3( + world.x, + world.y, + world.z)); + phys->getPhysicsWrapper() + ->setVelocities( + rb.bodyID, + Ogre::Vector3:: + ZERO, + Ogre::Vector3:: + ZERO); + } + } + editScene::EventParams enter; + enter.setString("vehicle", "sedan1"); + EventBus::getInstance().send("vehicle_enter", + enter); + carEnterWait = 0; + } + phase = 10; + return true; + } + case 10: { /* Scheme resolution: determinism + LRU hit. */ GeneratedInteriorService *svc = app->getGeneratedInteriorService(); @@ -5466,6 +7834,53 @@ struct InteriorTestListener : public Ogre::FrameListener { return true; } + /* Wait for the vehicle_enter to seat the player, + * then step out and record the car's parked pose. */ + if (!carTouched) { + VehicleControllerSystem *vcs = + app->getVehicleControllerSystem(); + flecs::entity car = + app->getWorld()->entity(carEntity); + if (vcs && car.is_alive() && + vcs->getDrivenVehicle() == car) { + vcs->exitVehicle(); + carTouched = true; + carEnterWait = 0; + return true; + } + if (car.is_alive() && + ++carEnterWait > 240) { + fail("vehicle enter for the car probe " + "timed out"); + return true; + } + return true; + } + /* Let the exit settle, then snapshot the expected + * world pose and verify the store captured it. */ + { + flecs::entity car = + app->getWorld()->entity(carEntity); + if (car.is_alive() && + car.has()) { + const auto &ct = + car.get(); + const Ogre::Vector3 rp = + ct.node ? + ct.node->_getDerivedPosition() : + ct.position; + JPH::DVec3 wp(rp.x, rp.y, rp.z); + if (RenderOriginSystem *ro = + RenderOriginSystem::getInstance()) + wp = ro->renderToWorld(rp); + carExpected = Ogre::Vector3( + (float)wp.GetX(), + (float)wp.GetY(), + (float)wp.GetZ()); + } + } + + logRoadMaterials("before"); SceneSwitchOptions opts; opts.targetEntityName = arrivalName; if (!app->switchScene(scheme, opts)) { @@ -5480,6 +7895,11 @@ struct InteriorTestListener : public Ogre::FrameListener { case 1: { if (!app->isInsideGeneratedInterior()) return true; + if (!VehicleStateStore::getInstance().find("sedan1")) { + fail("touched car was not captured on the " + "interior switch"); + return true; + } /* The interior grid must be loaded and match the * plan. */ flecs::entity interior; @@ -5511,6 +7931,49 @@ struct InteriorTestListener : public Ogre::FrameListener { " != plan " + std::to_string(expected)); return true; } + /* Complete standalone interior: room metadata drives + * furniture, and the stairwell is present. */ + { + const CellGridComponent &g = + interior.get(); + int rooms = 0; + bool stairRoom = false; + interior.children([&](flecs::entity child) { + if (!child.has()) + return true; + ++rooms; + if (child.get() + .roomType == "stairwell") + stairRoom = true; + return true; + }); + int stairCells = 0; + for (const Cell &cell : g.cells) + if (cell.flags & CellFlags::StairWell) + ++stairCells; + if (rooms != 5 || !stairRoom) { + fail("interior room metadata wrong (" + + std::to_string(rooms) + + " rooms, stair " + + (stairRoom ? "yes" : "no") + ")"); + return true; + } + if (stairCells != 4) { + fail("interior stairwell cells " + + std::to_string(stairCells) + + " != 4"); + return true; + } + /* RoomLayoutSystem furnishes the rooms on the + * frames after the load. */ + if (g.furnitureCells.empty()) { + if (++interiorFurnitureWait < 120) + return true; + fail("interior rooms were not furnished"); + return true; + } + interiorFurnitureWait = 0; + } flecs::entity player = app->getPlayerCharacterEntity(); if (!player.is_alive() || @@ -5694,6 +8157,9 @@ struct InteriorTestListener : public Ogre::FrameListener { if (!pt.node) return true; const Ogre::Vector3 pp = pt.node->getPosition(); + if (std::getenv("DEMO_ROAD_MAT_LOG") && + frame % 30 == 0) + logRoadMaterials("after"); if ((pp - exitPos).length() > 1.5f) { fail("player did not return to the pre-wired " "approach pose (got " + @@ -5704,6 +8170,47 @@ struct InteriorTestListener : public Ogre::FrameListener { ")"); return true; } + /* Player-touched car: the interior round trip and the + * save/load re-entry must keep its last pose. */ + { + /* The exterior scene was reloaded: find the + * car by its stable scene name. */ + flecs::entity car; + app->getWorld()->each( + [&](flecs::entity e, + EntityNameComponent &n) { + if (e.has() && + n.name == "sedan1") + car = e; + }); + if (!car.is_alive() || + !car.has()) { + fail("touched car missing after the " + "interior round trip"); + return true; + } + const auto &ct = + car.get(); + const Ogre::Vector3 rp = + ct.node ? ct.node->_getDerivedPosition() : + ct.position; + JPH::DVec3 wp(rp.x, rp.y, rp.z); + if (RenderOriginSystem *ro = + RenderOriginSystem::getInstance()) + wp = ro->renderToWorld(rp); + const Ogre::Vector3 pp2((float)wp.GetX(), + (float)wp.GetY(), + (float)wp.GetZ()); + if ((pp2 - carExpected).length() > 1.5f) { + fail("touched car reset (got " + + Ogre::StringConverter::toString(pp2) + + ", want " + + Ogre::StringConverter::toString( + carExpected) + + ")"); + return true; + } + } std::cout << "[test] PASS: generated interior entered, " "matched the plan, survived a save/load " "re-entry and returned through the " @@ -11866,15 +14373,39 @@ struct EmergencyTestListener : public Ogre::FrameListener { * the expected Y from TerrainSystem::getHeightAt and * polls while the streaming window loads and the * grounding watchdog re-clamps); exits 0 on PASS. + * --test-menu-new-game + * headless-friendly phase 8.2-follow-up check of the + * startup-menu NEW GAME path: startNewGame() must + * survive its caller component being destroyed by the + * teardown (the path reference was dangling and the + * load failed with a freed filename); exits 0 on + * PASS. + * --test-town-interior + * headless-friendly phase 8.2-follow-up check on the + * streamed town: enters a real multi-storey generated + * building with a scheduled NPC inside, verifies the + * interior cells, rooms, automatic stairwell, + * furniture and interior NPCs, then exits at the + * pre-wired pose; exits 0 on PASS. + * --test-stolen-car + * headless-friendly phase 8.2-follow-up check of a + * carjacked NPC traffic car: the car leaves the fleet + * and keeps its last pose across a save/load, the + * owner walks on the theft day and gets a fresh car + * the next in-game day while the abandoned one stays + * put; exits 0 on PASS. * --test-interior * headless-friendly phase 8.2 check on the flat * vehicletest floor: registers a synthetic two-storey * building plan, resolves its generated-interior - * document (determinism + LRU), switches in, verifies - * the interior grid/arrival marker, saves, loads (the - * deferred re-entry rebuilds and resumes the player on - * the arrival marker), then exits through the interior - * door's pre-wired exterior pose; exits 0 on PASS. + * document (determinism + LRU), takes a sedan around + * so it is player-touched, switches in, verifies the + * interior grid, rooms, automatic stairwell and + * furniture, saves, loads (the deferred re-entry + * rebuilds and resumes the player on the arrival + * marker), then exits through the interior door's + * pre-wired exterior pose and checks the touched car + * kept its pose; exits 0 on PASS. * --test-vehicle-drive * headless-friendly sedan enter/drive/exit check * (Phase 1): loads the exterior scene directly (or the @@ -11923,18 +14454,17 @@ int main(int argc, char *argv[]) #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. */ + * root is the working directory the binary runs from. The + * config is applied after argument parsing: an interactive + * run clears startScene to show the startup menu. */ 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); - } + ProjectConfig projectCfg; + projectCfg.rootDir = + std::filesystem::current_path().string(); + projectCfg.appName = EDITSCENE_PROJECT_APP_NAME; + projectCfg.startScene = EDITSCENE_PROJECT_START_SCENE; + projectCfg.gameMode = EDITSCENE_PROJECT_GAME_MODE; + projectCfg.loaded = true; #else DemoApp app; #endif @@ -11942,6 +14472,7 @@ int main(int argc, char *argv[]) bool headless = false; bool exitAfterFirstFrame = false; + bool anyTest = false; bool testSwitch = false; bool testVehicleDrive = false; bool testVehicleTurn = false; @@ -11966,7 +14497,11 @@ int main(int argc, char *argv[]) bool testParking = false; bool testFurniture = false; bool testStairs = false; + bool testFall = false; bool testInterior = false; + bool testStolenCar = false; + bool testTownInterior = false; + bool testMenuNewGame = false; bool testAnimTravel = false; bool testPoi = false; bool benchmark = false; @@ -11998,8 +14533,10 @@ int main(int argc, char *argv[]) bool disableStatsAutoLoad = false; for (int i = 1; i < argc; i++) { Ogre::String arg = argv[i]; - if (arg.rfind("--test-", 0) == 0) + if (arg.rfind("--test-", 0) == 0) { disableStatsAutoLoad = true; + anyTest = true; + } if (arg == "--headless") { headless = true; } else if (arg == "--exit-after-first-frame") { @@ -12052,8 +14589,16 @@ int main(int argc, char *argv[]) testFurniture = true; } else if (arg == "--test-stairs") { testStairs = true; + } else if (arg == "--test-fall") { + testFall = true; } else if (arg == "--test-interior") { testInterior = true; + } else if (arg == "--test-stolen-car") { + testStolenCar = true; + } else if (arg == "--test-town-interior") { + testTownInterior = true; + } else if (arg == "--test-menu-new-game") { + testMenuNewGame = true; } else if (arg == "--test-anim-travel") { testAnimTravel = true; } else if (arg == "--test-poi") { @@ -12173,6 +14718,12 @@ int main(int argc, char *argv[]) * (its probe POIs merge into the town's POI database). */ if (testPoi && !sceneArgGiven) sceneFile = "demo_scene_exterior.json"; + /* The stolen-car probe needs the generated town + traffic. */ + if (testStolenCar && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; + /* The real-town interior probe needs the streamed town. */ + if (testTownInterior && !sceneArgGiven) + sceneFile = "demo_scene_exterior.json"; /* The detail-tier test too. */ /* The detail-tier test too. */ if (testDetail && !sceneArgGiven) @@ -12217,6 +14768,21 @@ int main(int argc, char *argv[]) /* The drive benchmark tours the town road graph. */ if (benchmarkDrive && !sceneArgGiven) sceneFile = "demo_scene_exterior.json"; + + /* Interactive runs (no flag/test/scene argument) show the + * startup menu; every automated path keeps the direct + * start so the test batteries are unaffected. */ + const bool automatedRun = + headless || anyTest || sceneArgGiven || + exitAfterFirstFrame || benchmark || benchmarkDrive || + shotFrame >= 0 || printPopulationFrame >= 0 || + printDetailFrame >= 0 || forcePos || freeCam || + !debugEnterVehicle.empty() || autoComply; +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT + if (!automatedRun) + projectCfg.startScene.clear(); + app.setProjectConfig(projectCfg); +#endif app.setHeadless(headless); if (disableStatsAutoLoad) CharacterStatStore::getInstance().setAutoLoadEnabled(false); @@ -12246,13 +14812,24 @@ int main(int argc, char *argv[]) /* 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); + if (!automatedRun) { + /* Interactive run: the engine loaded startup_menu.json + * and shows the main menu. NEW GAME opens the demo's + * project start scene (the startup interior), which + * is also what the embedded project loads directly in + * automated runs. */ + app.getWorld()->each( + [&](flecs::entity e, + StartupMenuComponent &menu) { + menu.newGameScene = sceneFile; + }); + std::cout << "[demo] startup menu shown (NEW GAME: " + << sceneFile << ")" << std::endl; + } else { + std::cout << "[demo] starting new game with scene: " + << sceneFile << std::endl; + app.startNewGame(sceneFile); + } /* Gameplay tuning: NPCs spawn at 85 m and despawn at 100 m; * traffic uses 110/165 m. The independent radii balance @@ -12282,7 +14859,8 @@ int main(int argc, char *argv[]) << std::endl; ExitAfterFirstFrameListener exitListener(app.getRoot()); - if (exitAfterFirstFrame && !testSwitch && !testVehicleDrive) + if (exitAfterFirstFrame && !testSwitch && !testVehicleDrive && + !testStolenCar && !testTownInterior && !testMenuNewGame) app.getRoot()->addFrameListener(&exitListener); SceneSwitchTestListener testListener(&app); @@ -12390,11 +14968,31 @@ int main(int argc, char *argv[]) if (testStairs) app.getRoot()->addFrameListener(&stairsListener); + /* Phase 8.4 gravity + step-up acceptance. */ + FallTestListener fallListener(&app); + if (testFall) + app.getRoot()->addFrameListener(&fallListener); + /* Phase 8.2: in-memory generated interior switching. */ InteriorTestListener interiorListener(&app, sceneFile); if (testInterior) app.getRoot()->addFrameListener(&interiorListener); + /* Phase 8.2 follow-up: carjacked NPC car ownership. */ + StolenCarTestListener stolenCarListener(&app); + if (testStolenCar) + app.getRoot()->addFrameListener(&stolenCarListener); + + /* Phase 8.2 follow-up: a real streamed town building. */ + TownInteriorTestListener townInteriorListener(&app); + if (testTownInterior) + app.getRoot()->addFrameListener(&townInteriorListener); + + /* Phase 8.2 follow-up: startup-menu NEW GAME lifetime. */ + MenuNewGameTestListener menuNewGameListener(&app); + if (testMenuNewGame) + app.getRoot()->addFrameListener(&menuNewGameListener); + /* Phase 6.7 travel probe: runs on the default interior * scene (no scene override needed). */ AnimTravelTestListener animTravelListener(&app); @@ -12943,6 +15541,66 @@ int main(int argc, char *argv[]) } } + if (testMenuNewGame) { + if (menuNewGameListener.failed) { + std::cerr << "[test] FAIL: " + << menuNewGameListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (menuNewGameListener.phase != 2) { + std::cerr << "[test] FAIL: incomplete " + "(menu new game phase " + << menuNewGameListener.phase + << ")" << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testTownInterior) { + if (townInteriorListener.failed) { + std::cerr << "[test] FAIL: " + << townInteriorListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (townInteriorListener.phase != 3) { + std::cerr << "[test] FAIL: incomplete " + "(town interior phase " + << townInteriorListener.phase + << ")" << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + + if (testStolenCar) { + if (stolenCarListener.failed) { + std::cerr << "[test] FAIL: " + << stolenCarListener.failReason + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + if (stolenCarListener.phase != 5) { + std::cerr << "[test] FAIL: incomplete " + "(stolen car phase " + << stolenCarListener.phase << ")" + << std::endl; + app.clearScene(); + app.closeApp(); + return 1; + } + } + if (testAnimTravel) { if (animTravelListener.failed) { std::cerr << "[test] FAIL: " diff --git a/src/features/editScene/demos/demo-taxi/stage_runtime.cmake b/src/features/editScene/demos/demo-taxi/stage_runtime.cmake index 0d786f9..eadffbf 100644 --- a/src/features/editScene/demos/demo-taxi/stage_runtime.cmake +++ b/src/features/editScene/demos/demo-taxi/stage_runtime.cmake @@ -118,6 +118,13 @@ foreach(f startup_menu.json character_registry.json character_class.json endif() endforeach() +# The demo owns its startup menu (the editor's generic copy points at an +# editor-only scene). Interactive runs show it; test flags start +# directly and never touch it. +if(EXISTS "${SRC_DIR}/startup_menu.json") + file(COPY "${SRC_DIR}/startup_menu.json" DESTINATION "${DEMO_DIR}") +endif() + # animation_tree.json is demo-owned (source tree, versioned with the demo; # it carries the "driving" locomotion state used by the sedan seats). # COPIED, not symlinked: AnimationTreeRegistry auto-saves on runtime tree diff --git a/src/features/editScene/demos/demo-taxi/startup_menu.json b/src/features/editScene/demos/demo-taxi/startup_menu.json new file mode 100644 index 0000000..2e3966e --- /dev/null +++ b/src/features/editScene/demos/demo-taxi/startup_menu.json @@ -0,0 +1,38 @@ +{ + "entities": [ + { + "children": [], + "id": 485, + "name": { + "name": "menu" + }, + "startupMenu": { + "fontName": "Kenney Bold.ttf", + "fontSize": 36.0, + "newGameScene": "demo_scene_interior.json", + "showLoadGame": true, + "showOptions": true, + "showQuit": true + }, + "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 + } + } + } + ], + "version": "1.0" +} diff --git a/src/features/editScene/demos/demo-taxi/town_layouts.txt b/src/features/editScene/demos/demo-taxi/town_layouts.txt index 351393c..5d17167 100644 --- a/src/features/editScene/demos/demo-taxi/town_layouts.txt +++ b/src/features/editScene/demos/demo-taxi/town_layouts.txt @@ -43,18 +43,26 @@ (parking (rect-front-center 5.2 5.2)))) ; Two-floor family house: living+kitchen downstairs, bedrooms up. +; The stairwell room is stacked on both floors (phase 8.3): a 6 x 2 +; cell room at the back with the flight in its low-z band, a walkway +; beside it and one landing at each end. The ground floor boards from +; the living room, the upper floor steps off into the bedroom. (layout two-floor-house :tags (house village town) :score 1 (let ((w 14) (d 12)) (shell w d :floors 2 :empty) (floor 0 - (room living1 (rect 0 0 0.5 1) :type living) - (room kitchen1 (rect 0.5 0 1 1) :type kitchen) + (room stair (rect 0 0 1 0.333) :type stairwell) + (room living1 (rect 0 0.333 0.5 1) :type living) + (room kitchen1 (rect 0.5 0.333 1 1) :type kitchen) + (connect stair living1) (connect living1 kitchen1) (door front center) (windows all)) (floor 1 - (room bedroom1 (rect 0 0 0.5 1) :type bedroom) - (room bedroom2 (rect 0.5 0 1 1) :type bedroom) + (room stair (rect 0 0 1 0.333) :type stairwell) + (room bedroom1 (rect 0 0.333 0.5 1) :type bedroom) + (room bedroom2 (rect 0.5 0.333 1 1) :type bedroom) + (connect stair bedroom1) (connect bedroom1 bedroom2) (windows all)) (roof gable 0 0 w d) @@ -89,12 +97,16 @@ (let ((w 20) (d 8)) (shell w d :floors 2 :empty) (floor 0 - (room reception (rect 0 0 1 1) :type shop) + (room stair (rect 0 0 1 0.5) :type stairwell) + (room reception (rect 0 0.5 1 1) :type shop) + (connect reception stair) (door front center) (windows all)) (floor 1 - (room unit1 (rect 0 0 0.5 1) :type living) - (room unit2 (rect 0.5 0 1 1) :type bedroom) + (room stair (rect 0 0 1 0.5) :type stairwell) + (room unit1 (rect 0 0.5 0.5 1) :type living) + (room unit2 (rect 0.5 0.5 1 1) :type bedroom) + (connect stair unit1) (connect unit1 unit2) (door front -8 :gallery) (windows all)) @@ -109,19 +121,19 @@ (let ((w 12) (d 12)) (shell w d :floors 2 :empty) (floor 0 - (room lobby (rect 0 0 0.333 1) :type living) - (room stair (rect 0.333 0.166 0.833 0.5) :type stairwell) - (room east (rect 0.833 0.166 1 0.5)) - (room back (rect 0.333 0 1 0.166) :windows none) - (room south (rect 0.333 0.5 1 1) :type living) + (room stair (rect 0 0 1 0.333) :type stairwell) + (room lobby (rect 0 0.333 0.5 1) :type living) + (room south (rect 0.5 0.333 1 1) :type living) (connect lobby stair) - (connect stair east) - (connect lobby back) - (connect lobby south) + (connect stair south) (door front -4) (windows all)) (floor 1 - (room hall (rect 0 0 1 1) :type living) + (room stair (rect 0 0 1 0.333) :type stairwell) + (room hall (rect 0 0.333 0.5 1) :type living) + (room south (rect 0.5 0.333 1 1) :type living) + (connect hall stair) + (connect stair south) (windows all)) (roof gable 0 0 w d) (parking (rect-front-center 5.2 5.2)))) diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp index ca47451..537baf8 100644 --- a/src/features/editScene/layoutlib/LayoutEval.cpp +++ b/src/features/editScene/layoutlib/LayoutEval.cpp @@ -1,5 +1,7 @@ #include "LayoutEval.hpp" +#include "Stairwell.hpp" + #include #include #include @@ -363,16 +365,19 @@ const std::vector &LayoutLibrary::examples() "(roof flat 0 6 4 12)\n" "(roof flat 8 6 12 12)\n" }, { "Two storeys, smaller upper floor (rooms-first)", - "(shell 12 8 :floors 2 :empty)\n" + "(shell 16 8 :floors 2 :empty)\n" + "; The upper floor covers 3/4 of the footprint; the" + " automatic\n; stacked stairwell room still fits on" + " both floors.\n" "(floor 0 (room living (rect 0 0 0.5 1))\n" " (room kitchen (rect 0.5 0 1 1))\n" " (connect living kitchen)\n" " (door front -2)\n" " (windows all :spacing 4))\n" - "(floor 1 (room loft (rect 0 0 0.5 1))\n" + "(floor 1 (room loft (rect 0 0 0.75 1))\n" " (windows all :spacing 4))\n" - "(roof flat 6 0 12 8 :level 1)\n" - "(roof gable 0 0 6 8 :height 2)\n" }, + "(roof flat 12 0 16 8 :level 1)\n" + "(roof gable 0 0 12 8 :height 2)\n" }, { "Two storeys, automatic stairwell (phase 7.3)", "(shell 12 8 :floors 2)\n" "(floor 0 (door front center)\n" @@ -380,42 +385,48 @@ const std::vector &LayoutLibrary::examples() "; Any layout with :floors 2 or more and no stairwell\n" "; room gets a straight-run stairwell carved by the\n" "; generator (boarding and step-off cells included).\n" }, - { "Two-storey house with a stairwell room (rooms-first)", + { "Two-storey house with a stacked stairwell room", "(shell 12 8 :floors 2 :empty)\n" - "(floor 0 (room hall (rect 0 0 0.166 1))\n" - " (room stair (rect 0.166 0.25 0.666 0.5)\n" + "; The stairwell room spans the full width at the back" + " and is\n; repeated on both floors; the run keeps one" + " landing at each end.\n" + "(floor 0 (room stair (rect 0 0 1 0.25)\n" " :type stairwell)\n" - " (room back (rect 0.166 0 1 0.25))\n" + " (room hall (rect 0 0.25 0.166 1))\n" + " (room back (rect 0.166 0.25 1 0.5))\n" " (room front (rect 0.166 0.5 1 1))\n" - " (room east (rect 0.666 0.25 1 0.5))\n" " (connect hall stair)\n" - " (connect stair east)\n" " (connect hall back)\n" " (connect hall front)\n" " (door front center)\n" " (windows all))\n" - "(floor 1 (room upstairs (rect 0 0 1 1))\n" + "(floor 1 (room stair (rect 0 0 1 0.25)\n" + " :type stairwell)\n" + " (room upstairs (rect 0 0.25 1 1))\n" + " (connect upstairs stair)\n" " (windows all))\n" "(roof gable :height 2)\n" }, - { "Grand lobby stair (wide stairwell room, phase 7.5)", + { "Lobby stair (stacked stairwell room, phase 8.3)", "(shell 12 12 :floors 2 :empty)\n" - "(floor 0 (room lobby (rect 0 0 0.333 1))\n" - " (room stair (rect 0.333 0.166 0.833 0.5)\n" + "; The stairwell room is repeated on both floors; its" + " flight band\n; keeps one landing at each end and a" + " walkway beside it.\n" + "(floor 0 (room stair (rect 0 0 1 0.333)\n" " :type stairwell)\n" - " (room east (rect 0.833 0.166 1 0.5))\n" - " (room back (rect 0.333 0 1 0.166))\n" - " (room south (rect 0.333 0.5 1 1))\n" + " (room lobby (rect 0 0.333 0.5 1))\n" + " (room south (rect 0.5 0.333 1 1))\n" " (connect lobby stair)\n" - " (connect stair east)\n" - " (connect lobby back)\n" - " (connect lobby south)\n" + " (connect stair south)\n" " (door front -4)\n" " (windows all))\n" - "(floor 1 (room hall (rect 0 0 1 1))\n" + "(floor 1 (room stair (rect 0 0 1 0.333)\n" + " :type stairwell)\n" + " (room hall (rect 0 0.333 0.5 1))\n" + " (room south (rect 0.5 0.333 1 1))\n" + " (connect hall stair)\n" + " (connect stair south)\n" " (windows all))\n" - "(roof gable :height 2)\n" - "; An elongated stairwell room (here 3 x 2 cells) builds\n" - "; ONE wide straight flight instead of a dogleg.\n" }, + "(roof gable :height 2)\n" }, { "Windowless bathroom (per-room :windows none)", "(shell 12 8 :empty)\n" "(floor 0 (room living (rect 0 0 0.5 1))\n" @@ -2045,18 +2056,36 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) c |= PlanFlags::Floor; if (top) c |= PlanFlags::Ceiling; - /* Phase 7.3: a stairwell room flags its - * cells; the CellGrid generator cuts the - * room's own ceiling and the floor of the - * cell above so the stairs pass through. */ - if (roomType == "stairwell") - c |= PlanFlags::StairWell; m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) * m_plan->cellsX + x] = roomId; } } + /* Phase 8.3: a stairwell room keeps one landing cell at + * each end of its long axis and a walkway band beside + * the flight; only the flight cells are flagged, on the + * floors that host a flight (all but the top floor). + * Rooms too small for that keep the legacy whole-room + * flight (a compact dogleg). */ + if (roomType == "stairwell" && + m_floor + 1 < m_plan->floors) { + const StairRun run = planStairRun( + rr.x0, rr.z0, rr.x1, rr.z1); + if (run.valid) { + for (int z = run.z0; z <= run.z1; ++z) + for (int x = run.x0; x <= run.x1; + ++x) + m_plan->at(x, m_floor, z) |= + PlanFlags::StairWell; + } else { + for (int z = rr.z0; z <= rr.z1; ++z) + for (int x = rr.x0; x <= rr.x1; + ++x) + m_plan->at(x, m_floor, z) |= + PlanFlags::StairWell; + } + } return true; } @@ -2105,15 +2134,26 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) c |= PlanFlags::IntWallZNeg; if (z == rr.z1 && rr.z1 < m_plan->cellsZ - 1) c |= PlanFlags::IntWallZPos; - /* Phase 7.3: a stairwell room also flags its - * cells (geometry effect in the CellGrid - * generator: ceiling/floor openings + stairs). */ - if (roomType == "stairwell") - c |= PlanFlags::StairWell; m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) * m_plan->cellsX + x] = roomId; } } + /* Phase 8.3: flag only the flight cells (see the rooms-first + * branch above); tiny rooms keep the legacy whole-room flight. */ + if (roomType == "stairwell" && m_floor + 1 < m_plan->floors) { + const StairRun run = planStairRun(rr.x0, rr.z0, rr.x1, rr.z1); + if (run.valid) { + for (int z = run.z0; z <= run.z1; ++z) + for (int x = run.x0; x <= run.x1; ++x) + m_plan->at(x, m_floor, z) |= + PlanFlags::StairWell; + } else { + for (int z = rr.z0; z <= rr.z1; ++z) + for (int x = rr.x0; x <= rr.x1; ++x) + m_plan->at(x, m_floor, z) |= + PlanFlags::StairWell; + } + } return true; } @@ -2135,9 +2175,67 @@ bool LayoutEval::verbIDoor(const std::vector &pos, const SExpr &at) const RoomRect &A = ra->second; const RoomRect &B = rb->second; + /* Phase 8.3: a door into a stairwell room must land on a landing + * or walkway cell, never beside the flight. When the midpoint + * falls on the flight band, move it to the nearest landing end + * that is still inside the shared overlap. */ + auto roomIsStairwell = [&](const std::string &name) { + auto it = m_roomNameIds.find(name); + return it != m_roomNameIds.end() && + (size_t)it->second < m_plan->roomTypes.size() && + m_plan->roomTypes[it->second] == "stairwell"; + }; + auto adjustStairDoorPos = [&](const RoomRect &room, bool stair, + bool posAlongZ, int fixed, int lo, + int hi, int &pos) -> bool { + if (!stair) + return true; + const StairRun run = + planStairRun(room.x0, room.z0, room.x1, room.z1); + if (!run.valid) + return true; + auto inRunCell = [&](int x, int z) { + return x >= run.x0 && x <= run.x1 && z >= run.z0 && + z <= run.z1; + }; + int x = posAlongZ ? fixed : pos; + int z = posAlongZ ? pos : fixed; + if (!inRunCell(x, z)) + return true; + /* Ground floor boards from the low end; upper floors + * step off at the high end (the landing the flight tops + * out on). Fall back to the other end when the shared + * overlap does not reach the preferred one. */ + const int boarding = run.alongX ? run.x0 - 1 : run.z0 - 1; + const int stepOff = run.alongX ? run.x1 + 1 : run.z1 + 1; + const int preferred = m_floor == 0 ? boarding : stepOff; + const int other = m_floor == 0 ? stepOff : boarding; + auto clampEnd = [&](int end) { + return std::max(lo, std::min(hi, end)); + }; + pos = clampEnd(preferred); + if (inRunCell(posAlongZ ? fixed : pos, + posAlongZ ? pos : fixed)) { + pos = clampEnd(other); + } + x = posAlongZ ? fixed : pos; + z = posAlongZ ? pos : fixed; + if (inRunCell(x, z)) + return false; + return true; + }; + /* A left of B (A's +X face shared with B's -X face)? */ if (A.x1 + 1 == B.x0 && A.z0 <= B.z1 && B.z0 <= A.z1) { int zm = (std::max(A.z0, B.z0) + std::min(A.z1, B.z1)) / 2; + const int zLo = std::max(A.z0, B.z0); + const int zHi = std::min(A.z1, B.z1); + if (!adjustStairDoorPos(A, roomIsStairwell(pos[0].str), true, + A.x1, zLo, zHi, zm) || + !adjustStairDoorPos(B, roomIsStairwell(pos[1].str), true, + B.x0, zLo, zHi, zm)) + return fail(at, "idoor would open beside the " + "stairwell flight"); uint64_t &ca = m_plan->at(A.x1, m_floor, zm); ca &= ~PlanFlags::IntWallXPos; ca |= PlanFlags::IntDoorXPos; @@ -2148,6 +2246,14 @@ bool LayoutEval::verbIDoor(const std::vector &pos, const SExpr &at) } if (B.x1 + 1 == A.x0 && A.z0 <= B.z1 && B.z0 <= A.z1) { int zm = (std::max(A.z0, B.z0) + std::min(A.z1, B.z1)) / 2; + const int zLo = std::max(A.z0, B.z0); + const int zHi = std::min(A.z1, B.z1); + if (!adjustStairDoorPos(B, roomIsStairwell(pos[1].str), true, + B.x1, zLo, zHi, zm) || + !adjustStairDoorPos(A, roomIsStairwell(pos[0].str), true, + A.x0, zLo, zHi, zm)) + return fail(at, "idoor would open beside the " + "stairwell flight"); uint64_t &cb = m_plan->at(B.x1, m_floor, zm); cb &= ~PlanFlags::IntWallXPos; cb |= PlanFlags::IntDoorXPos; @@ -2159,6 +2265,14 @@ bool LayoutEval::verbIDoor(const std::vector &pos, const SExpr &at) /* A in front of B (A's -Z face shared with B's +Z face)? */ if (A.z1 + 1 == B.z0 && A.x0 <= B.x1 && B.x0 <= A.x1) { int xm = (std::max(A.x0, B.x0) + std::min(A.x1, B.x1)) / 2; + const int xLo = std::max(A.x0, B.x0); + const int xHi = std::min(A.x1, B.x1); + if (!adjustStairDoorPos(A, roomIsStairwell(pos[0].str), false, + A.z1, xLo, xHi, xm) || + !adjustStairDoorPos(B, roomIsStairwell(pos[1].str), false, + B.z0, xLo, xHi, xm)) + return fail(at, "idoor would open beside the " + "stairwell flight"); uint64_t &ca = m_plan->at(xm, m_floor, A.z1); ca &= ~PlanFlags::IntWallZPos; ca |= PlanFlags::IntDoorZPos; @@ -2169,6 +2283,14 @@ bool LayoutEval::verbIDoor(const std::vector &pos, const SExpr &at) } if (B.z1 + 1 == A.z0 && A.x0 <= B.x1 && B.x0 <= A.x1) { int xm = (std::max(A.x0, B.x0) + std::min(A.x1, B.x1)) / 2; + const int xLo = std::max(A.x0, B.x0); + const int xHi = std::min(A.x1, B.x1); + if (!adjustStairDoorPos(B, roomIsStairwell(pos[1].str), false, + B.z1, xLo, xHi, xm) || + !adjustStairDoorPos(A, roomIsStairwell(pos[0].str), false, + A.z0, xLo, xHi, xm)) + return fail(at, "idoor would open beside the " + "stairwell flight"); uint64_t &cb = m_plan->at(xm, m_floor, B.z1); cb &= ~PlanFlags::IntWallZPos; cb |= PlanFlags::IntDoorZPos; @@ -2773,6 +2895,31 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out, finalizeUnionWalls(); warnUnreachableRooms(); } + /* Phase 8.3: every multi-floor plan carries a stacked stairwell + * room (inserted when the layout authored none, completed when it + * declared rooms on only some floors). Warning-only: exotic + * layouts keep building with a legacy carved run. */ + if (out.floors >= 2) { + std::string stairWarn; + synthesizeStairwell(out, &stairWarn); + if (!stairWarn.empty()) + warn(stairWarn); + std::string stairError; + if (!validateStairwells(out, &stairError)) { + /* No usable stacked stairwell (tiny/degenerate + * shell): give every upper floor a motel-style + * exterior stair instead of relying on the legacy + * carve, which tops the flight out against a + * room wall. */ + std::string extWarn; + addExteriorStairAccess(out, &extWarn); + if (!extWarn.empty()) + warn(extWarn); + std::string accessError; + if (!validateVerticalAccess(out, &accessError)) + warn("vertical access: " + accessError); + } + } return true; } diff --git a/src/features/editScene/layoutlib/Stairwell.cpp b/src/features/editScene/layoutlib/Stairwell.cpp new file mode 100644 index 0000000..16f3b0a --- /dev/null +++ b/src/features/editScene/layoutlib/Stairwell.cpp @@ -0,0 +1,809 @@ +#include "Stairwell.hpp" + +#include +#include +#include + +namespace LayoutLib { + +namespace { + +/* Vertical flight cells that fit inside the room after the two + * landings. With the default 2 m cells a 4-cell run is ~7.7 m of + * tread, matching the CellGrid stair builder's comfortable range. */ +constexpr int STAIRWELL_MIN_RUN_CELLS = 3; +constexpr int STAIRWELL_PREFERRED_RUN_CELLS = 4; +constexpr int STAIRWELL_ROOM_WIDTH = 2; /* flight band + walkway */ + +constexpr uint64_t kInternalDoorBits = + PlanFlags::IntDoorXNeg | PlanFlags::IntDoorXPos | + PlanFlags::IntDoorZPos | PlanFlags::IntDoorZNeg; +constexpr uint64_t kInternalWallBits = + PlanFlags::IntWallXNeg | PlanFlags::IntWallXPos | + PlanFlags::IntWallZPos | PlanFlags::IntWallZNeg; +constexpr uint64_t kInternalWindowBits = + PlanFlags::IntWindowXNeg | PlanFlags::IntWindowXPos | + PlanFlags::IntWindowZPos | PlanFlags::IntWindowZNeg; +constexpr uint64_t kExteriorOpeningBits = + PlanFlags::DoorXNeg | PlanFlags::DoorXPos | + PlanFlags::DoorZPos | PlanFlags::DoorZNeg | + PlanFlags::ExteriorStair; + +inline bool inside(const BuildingPlan &plan, int x, int z) +{ + return x >= 0 && z >= 0 && x < plan.cellsX && z < plan.cellsZ; +} + +inline int64_t cellKey(int x, int y, int z) +{ + return ((int64_t)y * 16384 + z) * 16384 + x; +} + +inline uint64_t at(const BuildingPlan &plan, int x, int y, int z) +{ + return plan.at(x, y, z); +} + +inline int roomAt(const BuildingPlan &plan, int x, int y, int z) +{ + if (plan.roomIds.empty()) + return -1; + return plan.roomIds[((size_t)y * plan.cellsZ + z) * plan.cellsX + + x]; +} + +/** Landings + walkway: every room cell that is not part of the flight + * footprint. */ +inline bool inRun(const StairRun &run, int x, int z) +{ + return run.valid && x >= run.x0 && x <= run.x1 && z >= run.z0 && + z <= run.z1; +} + +int uniqueRoomId(BuildingPlan &plan, const std::string &base) +{ + std::set names(plan.roomNames.begin(), + plan.roomNames.end()); + std::string name = base; + int suffix = 2; + while (names.count(name)) + name = base + std::to_string(suffix++); + const int id = (int)plan.roomNames.size(); + plan.roomNames.push_back(name); + plan.roomTypes.push_back("stairwell"); + plan.roomFurnitureTags.push_back({}); + plan.roomCompositions.push_back({}); + plan.roomStatEffects.push_back({}); + return id; +} + +/** Append the stairwell room id to the plan's room tables when the + * instance is new (existing stairwell rooms keep their id). */ +int stairRoomId(BuildingPlan &plan, const std::string &base) +{ + for (size_t i = 0; i < plan.roomTypes.size(); ++i) + if (plan.roomTypes[i] == "stairwell") + return (int)i; + return uniqueRoomId(plan, base); +} + +/** Clear the interior flags strictly covering the rect and re-claim + * the cells for `roomId`; the perimeter interior walls are rebuilt + * with the usual convention (edges on the grid border are exterior + * walls and get nothing). */ +void claimRect(BuildingPlan &plan, int floor, int minX, int minZ, + int maxX, int maxZ, int roomId) +{ + for (int z = minZ; z <= maxZ; ++z) { + for (int x = minX; x <= maxX; ++x) { + uint64_t &c = plan.at(x, floor, z); + c &= ~(kInternalWallBits | kInternalDoorBits | + kInternalWindowBits); + c |= PlanFlags::Floor; + if (plan.roomIds.empty()) + plan.roomIds.assign( + (size_t)plan.cellsX * plan.cellsZ * + plan.floors, + -1); + plan.roomIds[((size_t)floor * plan.cellsZ + z) * + plan.cellsX + + x] = roomId; + } + } + /* Perimeter interior walls (same rule as the room verb: an edge + * on the grid border is the exterior wall, not an interior one). */ + for (int z = minZ; z <= maxZ; ++z) { + for (int x = minX; x <= maxX; ++x) { + uint64_t &c = plan.at(x, floor, z); + if (x == minX && minX > 0) + c |= PlanFlags::IntWallXNeg; + if (x == maxX && maxX < plan.cellsX - 1) + c |= PlanFlags::IntWallXPos; + if (z == minZ && minZ > 0) + c |= PlanFlags::IntWallZNeg; + if (z == maxZ && maxZ < plan.cellsZ - 1) + c |= PlanFlags::IntWallZPos; + } + } +} + +/** Flag the flight cells on every floor that hosts a flight. */ +void flagRuns(BuildingPlan &plan, int minX, int minZ, int maxX, int maxZ, + const StairRun &run, int roomId) +{ + if (!run.valid) + return; + for (int y = 0; y + 1 < plan.floors; ++y) { + for (int z = run.z0; z <= run.z1; ++z) { + for (int x = run.x0; x <= run.x1; ++x) { + if (roomAt(plan, x, y, z) != roomId) + continue; + plan.at(x, y, z) |= PlanFlags::StairWell; + } + } + } +} + +/** One interior door from the stairwell room to a neighbouring room, + * placed on a landing or walkway cell (never beside the flight) and + * never through an exterior wall/opening. */ +bool connectStairRoom(BuildingPlan &plan, int floor, int roomId, + const StairRun &run, bool preferMaxEnd) +{ + if (plan.roomIds.empty()) + return false; + std::set seen; + std::vector > cells; + const int y = floor; + int cellsAtFloor = 0; + for (int z = 0; z < plan.cellsZ; ++z) + for (int x = 0; x < plan.cellsX; ++x) + if (roomAt(plan, x, y, z) == roomId) { + ++cellsAtFloor; + if (!inRun(run, x, z)) + seen.insert(cellKey(x, 0, z)); + } + if (cellsAtFloor == 0) + return false; + /* Landing cells first (the long-axis ends), then the walkway. */ + std::vector > landings; + std::vector > others; + for (int64_t key : seen) { + const int z = (int)(key / 16384 % 16384); + const int x = (int)(key % 16384); + const bool endCell = + run.valid && + ((run.alongX && (x == run.x0 - 1 || x == run.x1 + 1)) || + (!run.alongX && + (z == run.z0 - 1 || z == run.z1 + 1))); + if (endCell) + landings.push_back({ x, z }); + else + others.push_back({ x, z }); + } + auto byEnd = [&](const std::pair &a, + const std::pair &b) { + if (!run.valid) + return a.first < b.first; + const int ka = run.alongX ? a.first : a.second; + const int kb = run.alongX ? b.first : b.second; + return preferMaxEnd ? ka > kb : ka < kb; + }; + std::sort(landings.begin(), landings.end(), byEnd); + std::sort(others.begin(), others.end(), byEnd); + cells = landings; + cells.insert(cells.end(), others.begin(), others.end()); + + /* Edge order: the walkway side first on the long axis, then the + * cross sides, so doors avoid the flight band. */ + struct Edge { + int dx, dz; + uint64_t doorBit, wallBit, windowBit, extDoorBit, + extWallBit, extWindowBit; + }; + const Edge edges[4] = { + { 0, 1, PlanFlags::IntDoorZPos, PlanFlags::IntWallZPos, + PlanFlags::IntWindowZPos, PlanFlags::DoorZPos, + PlanFlags::WallZPos, PlanFlags::WindowZPos }, + { 0, -1, PlanFlags::IntDoorZNeg, PlanFlags::IntWallZNeg, + PlanFlags::IntWindowZNeg, PlanFlags::DoorZNeg, + PlanFlags::WallZNeg, PlanFlags::WindowZNeg }, + { 1, 0, PlanFlags::IntDoorXPos, PlanFlags::IntWallXPos, + PlanFlags::IntWindowXPos, PlanFlags::DoorXPos, + PlanFlags::WallXPos, PlanFlags::WindowXPos }, + { -1, 0, PlanFlags::IntDoorXNeg, PlanFlags::IntWallXNeg, + PlanFlags::IntWindowXNeg, PlanFlags::DoorXNeg, + PlanFlags::WallXNeg, PlanFlags::WindowXNeg }, + }; + for (const auto &cell : cells) { + const int x = cell.first; + const int z = cell.second; + uint64_t &c = plan.at(x, y, z); + for (const Edge &e : edges) { + const int nx = x + e.dx; + const int nz = z + e.dz; + if (!inside(plan, nx, nz)) + continue; + if (!(at(plan, nx, y, nz) & PlanFlags::Floor)) + continue; + const int nId = roomAt(plan, nx, y, nz); + if (nId == roomId) + continue; + /* Never open through an exterior surface. */ + const uint64_t extMask = + e.extDoorBit | e.extWallBit | e.extWindowBit; + if (c & extMask) + continue; + if (plan.at(nx, y, nz) & e.extDoorBit) + continue; + uint64_t &n = plan.at(nx, y, nz); + c &= ~(e.wallBit | e.windowBit); + c |= e.doorBit; + const uint64_t oppositeWall = + e.dx == 1 ? PlanFlags::IntWallXNeg : + e.dx == -1 ? PlanFlags::IntWallXPos : + e.dz == 1 ? PlanFlags::IntWallZNeg : + PlanFlags::IntWallZPos; + const uint64_t oppositeWindow = + e.dx == 1 ? PlanFlags::IntWindowXNeg : + e.dx == -1 ? PlanFlags::IntWindowXPos : + e.dz == 1 ? PlanFlags::IntWindowZNeg : + PlanFlags::IntWindowZPos; + const uint64_t oppositeDoor = + e.dx == 1 ? PlanFlags::IntDoorXNeg : + e.dx == -1 ? PlanFlags::IntDoorXPos : + e.dz == 1 ? PlanFlags::IntDoorZNeg : + PlanFlags::IntDoorZPos; + n &= ~(oppositeWall | oppositeWindow); + n |= oppositeDoor; + return true; + } + } + return false; +} + +/** Deterministic rect search. Returns true when a rectangle that has + * floor on EVERY floor was found. */ +bool chooseStairRect(const BuildingPlan &plan, int runCells, int width, + int &outMinX, int &outMinZ, int &outMaxX, + int &outMaxZ) +{ + const int len = runCells + 2; + int bestScore = -1; + bool found = false; + auto tryAxis = [&](bool alongX) { + const int axisLen = alongX ? plan.cellsX : plan.cellsZ; + const int crossLen = alongX ? plan.cellsZ : plan.cellsX; + if (len > axisLen || width > crossLen) + return; + for (int start = 0; start + len <= axisLen; ++start) { + for (int cross = 0; cross + width <= crossLen; + ++cross) { + const int minX = + alongX ? start : cross; + const int maxX = + alongX ? start + len - 1 : + cross + width - 1; + const int minZ = + alongX ? cross : start; + const int maxZ = + alongX ? cross + width - 1 : + start + len - 1; + bool ok = true; + int sameRoomCount = 0; + int firstRoom = -2; + int perimeter = 0; + for (int y = 0; y < plan.floors && ok; ++y) { + for (int z = minZ; z <= maxZ && ok; + ++z) { + for (int x = minX; + x <= maxX && ok; ++x) { + const uint64_t c = + at(plan, x, + y, z); + if (!(c & PlanFlags::Floor)) + ok = false; + if (c & kExteriorOpeningBits) + ok = false; + if (y == 0) { + const int rid = + roomAt(plan, + x, + y, + z); + if (firstRoom == + -2) + firstRoom = + rid; + if (rid == + firstRoom) + ++sameRoomCount; + if (x == 0 || + z == 0 || + x == + plan.cellsX - + 1 || + z == + plan.cellsZ - + 1) + ++perimeter; + } + } + } + } + if (!ok) + continue; + int score = sameRoomCount * 50 + + perimeter * 5; + if (!found || score > bestScore) { + found = true; + bestScore = score; + outMinX = minX; + outMinZ = minZ; + outMaxX = maxX; + outMaxZ = maxZ; + } + } + } + }; + /* Prefer a run along the longer grid axis, but try both. */ + if (plan.cellsX >= plan.cellsZ) { + tryAxis(true); + tryAxis(false); + } else { + tryAxis(false); + tryAxis(true); + } + return found; +} + +} /* namespace */ + +StairRun planStairRun(int minX, int minZ, int maxX, int maxZ) +{ + StairRun run; + const int w = maxX - minX + 1; + const int l = maxZ - minZ + 1; + if (w >= STAIRWELL_MIN_ROOM_LEN) { + run.valid = true; + run.alongX = true; + run.x0 = minX + 1; + run.x1 = maxX - 1; + run.z0 = run.z1 = minZ; + } else if (l >= STAIRWELL_MIN_ROOM_LEN) { + run.valid = true; + run.alongX = false; + run.z0 = minZ + 1; + run.z1 = maxZ - 1; + run.x0 = run.x1 = minX; + } + return run; +} + +std::vector +planStairwellRooms(const BuildingPlan &plan) +{ + std::vector out; + if (!plan.valid() || plan.roomIds.empty()) + return out; + std::set stairIds; + for (size_t i = 0; i < plan.roomTypes.size(); ++i) + if (plan.roomTypes[i] == "stairwell") + stairIds.insert((int)i); + if (stairIds.empty()) + return out; + std::vector acc; + for (int y = 0; y < plan.floors; ++y) { + std::vector ids(stairIds.begin(), stairIds.end()); + for (int id : ids) { + StairwellRoomInstance inst; + inst.roomId = id; + inst.floor = y; + bool any = false; + for (int z = 0; z < plan.cellsZ; ++z) { + for (int x = 0; x < plan.cellsX; ++x) { + if (!(plan.at(x, y, z) & + PlanFlags::Floor)) + continue; + if (roomAt(plan, x, y, z) != id) + continue; + if (!any) { + inst.minX = inst.maxX = x; + inst.minZ = inst.maxZ = z; + any = true; + } else { + inst.minX = + std::min(inst.minX, x); + inst.maxX = + std::max(inst.maxX, x); + inst.minZ = + std::min(inst.minZ, z); + inst.maxZ = + std::max(inst.maxZ, z); + } + } + } + if (any) + acc.push_back(inst); + } + } + /* One instance per (floor, room): collapse duplicates from + * multi-rect claims (keep the bounding rect). */ + std::sort(acc.begin(), acc.end(), + [](const StairwellRoomInstance &a, + const StairwellRoomInstance &b) { + if (a.floor != b.floor) + return a.floor < b.floor; + if (a.roomId != b.roomId) + return a.roomId < b.roomId; + if (a.minX != b.minX) + return a.minX < b.minX; + return a.minZ < b.minZ; + }); + for (const StairwellRoomInstance &inst : acc) { + if (!out.empty() && out.back().floor == inst.floor && + out.back().roomId == inst.roomId) { + out.back().minX = + std::min(out.back().minX, inst.minX); + out.back().minZ = + std::min(out.back().minZ, inst.minZ); + out.back().maxX = + std::max(out.back().maxX, inst.maxX); + out.back().maxZ = + std::max(out.back().maxZ, inst.maxZ); + continue; + } + out.push_back(inst); + } + return out; +} + +bool synthesizeStairwell(BuildingPlan &plan, std::string *warning) +{ + if (!plan.valid() || plan.floors < 2) + return false; + + std::vector existing = + planStairwellRooms(plan); + if (!existing.empty()) { + /* Reference rect: the floor instance with the largest area + * (deterministic tie-break by floor). */ + const StairwellRoomInstance *ref = &existing.front(); + for (const auto &inst : existing) { + const int area = (inst.maxX - inst.minX + 1) * + (inst.maxZ - inst.minZ + 1); + const int refArea = (ref->maxX - ref->minX + 1) * + (ref->maxZ - ref->minZ + 1); + if (area > refArea) + ref = &inst; + } + const int roomId = ref->roomId; + const StairRun run = planStairRun(ref->minX, ref->minZ, + ref->maxX, ref->maxZ); + if (!run.valid) { + /* Too small for a landing-plus-run flight: the + * whole-room flight would top out against the + * room wall, so demote the room to a normal room + * and let the exterior-stair fallback provide the + * vertical access instead. */ + for (int y = 0; y < plan.floors; ++y) + for (int z = 0; z < plan.cellsZ; ++z) + for (int x = 0; x < plan.cellsX; ++x) + if (roomAt(plan, x, y, z) == roomId) + plan.at(x, y, z) &= + ~PlanFlags::StairWell; + if ((size_t)roomId < plan.roomTypes.size()) + plan.roomTypes[roomId].clear(); + if (warning) + *warning = "stairwell room is too small for " + "a landing-plus-run flight; using " + "motel-style exterior stairs"; + return false; + } + bool changed = false; + for (int y = 0; y < plan.floors; ++y) { + /* Missing floor instance, or a different rect: + * re-claim the reference rect. */ + bool same = false; + for (const auto &inst : existing) { + if (inst.floor != y) + continue; + if (inst.minX == ref->minX && + inst.minZ == ref->minZ && + inst.maxX == ref->maxX && + inst.maxZ == ref->maxZ) + same = true; + } + if (same) + continue; + claimRect(plan, y, ref->minX, ref->minZ, ref->maxX, + ref->maxZ, roomId); + changed = true; + } + if (run.valid) { + /* Re-flag flight cells (idempotent) and make sure + * every floor connects to a neighbour. */ + flagRuns(plan, ref->minX, ref->minZ, ref->maxX, + ref->maxZ, run, roomId); + } + for (int y = 0; y < plan.floors; ++y) { + bool hasDoor = false; + for (int z = ref->minZ; z <= ref->maxZ && !hasDoor; ++z) + for (int x = ref->minX; + x <= ref->maxX && !hasDoor; ++x) { + if (roomAt(plan, x, y, z) != roomId) + continue; + if (plan.at(x, y, z) & + kInternalDoorBits) + hasDoor = true; + } + if (!hasDoor) + connectStairRoom(plan, y, roomId, run, + y > 0); + } + if (warning && !run.valid) + *warning = "stairwell room is too small for a " + "landing-plus-run flight; using the " + "legacy carve"; + return changed; + } + + /* No stairwell room: find a rect. Try the preferred run length, + * then progressively shorter runs and a walkway-less room. */ + int minX = 0, minZ = 0, maxX = 0, maxZ = 0; + bool found = false; + for (int runCells = STAIRWELL_PREFERRED_RUN_CELLS; + runCells >= STAIRWELL_MIN_RUN_CELLS && !found; --runCells) { + if (chooseStairRect(plan, runCells, + STAIRWELL_ROOM_WIDTH, minX, minZ, maxX, + maxZ)) + found = true; + } + for (int runCells = STAIRWELL_PREFERRED_RUN_CELLS; + runCells >= STAIRWELL_MIN_RUN_CELLS && !found; --runCells) { + if (chooseStairRect(plan, runCells, 1, minX, minZ, maxX, + maxZ)) + found = true; + } + if (!found) { + if (warning) + *warning = "no room for a stacked stairwell room; " + "using the legacy carve"; + return false; + } + const int roomId = stairRoomId(plan, "stair"); + for (int y = 0; y < plan.floors; ++y) + claimRect(plan, y, minX, minZ, maxX, maxZ, roomId); + const StairRun run = planStairRun(minX, minZ, maxX, maxZ); + flagRuns(plan, minX, minZ, maxX, maxZ, run, roomId); + for (int y = 0; y < plan.floors; ++y) + connectStairRoom(plan, y, roomId, run, y > 0); + return true; +} + +namespace { + +/* Facade sides, matching CellGridSystem's exterior-stair table: + * 0 = ZPos, 1 = ZNeg, 2 = XNeg, 3 = XPos. */ +constexpr uint64_t kSideDoor[4] = { + PlanFlags::DoorZPos, PlanFlags::DoorZNeg, + PlanFlags::DoorXNeg, PlanFlags::DoorXPos +}; +constexpr uint64_t kSideWin[4] = { + PlanFlags::WindowZPos, PlanFlags::WindowZNeg, + PlanFlags::WindowXNeg, PlanFlags::WindowXPos +}; +constexpr uint64_t kSideWall[4] = { + PlanFlags::WallZPos, PlanFlags::WallZNeg, + PlanFlags::WallXNeg, PlanFlags::WallXPos +}; +constexpr uint64_t kAnyDoor = kSideDoor[0] | kSideDoor[1] | + kSideDoor[2] | kSideDoor[3]; + +} /* namespace */ + +bool addExteriorStairAccess(BuildingPlan &plan, std::string *note) +{ + if (!plan.valid() || plan.floors < 2) + return false; + bool anyAdded = false; + for (int y = 1; y < plan.floors; ++y) { + bool has = false; + for (int z = 0; z < plan.cellsZ && !has; ++z) + for (int x = 0; x < plan.cellsX && !has; ++x) + if (plan.at(x, y, z) & + PlanFlags::ExteriorStair) + has = true; + if (has) + continue; + /* Candidates: floored boundary cells with one exterior + * door (reuse it), or a wall/window side we can turn + * into the gallery door. Prefer the facade with the most + * along-facade room (the flight needs a run). */ + int bestScore = -1; + int bestX = -1, bestZ = -1, bestSide = -1; + for (int z = 0; z < plan.cellsZ; ++z) { + for (int x = 0; x < plan.cellsX; ++x) { + const uint64_t c = plan.at(x, y, z); + if (!(c & PlanFlags::Floor)) + continue; + int doorCount = 0; + for (int s = 0; s < 4; ++s) + if (c & kSideDoor[s]) + ++doorCount; + if (doorCount > 1) + continue; + for (int s = 0; s < 4; ++s) { + const bool hasDoor = + (c & kSideDoor[s]) != 0; + const bool hasOpening = + hasDoor || + (c & kSideWin[s]) != 0 || + (c & kSideWall[s]) != 0; + if (!hasOpening) + continue; + if (!hasDoor && doorCount > 0) + continue; + const bool alongX = + s < 2; /* Z faces run along X */ + const int alongCount = + alongX ? plan.cellsX : + plan.cellsZ; + const int alongCell = + alongX ? x : z; + const int avail = std::max( + alongCell, + alongCount - 1 - alongCell); + int score = avail * 10; + if (hasDoor) + score += 500; /* no carving */ + else if (c & kSideWin[s]) + score += 50; + /* Penalize a cell right above a + * ground-floor exterior door: the + * flight would land across it. */ + const uint64_t ground = + plan.at(x, 0, z); + if (ground & kAnyDoor) + score -= 200; + if (score > bestScore) { + bestScore = score; + bestX = x; + bestZ = z; + bestSide = s; + } + } + } + } + if (bestSide < 0) { + if (note) + *note = "no upper floor has a facade for an " + "exterior stair"; + return anyAdded; + } + uint64_t &c = plan.at(bestX, y, bestZ); + /* Carve the gallery door on the chosen facade side. */ + c &= ~(kSideWall[bestSide] | kSideWin[bestSide]); + c |= kSideDoor[bestSide]; + c |= PlanFlags::ExteriorStair; + anyAdded = true; + } + if (note && anyAdded) + *note = "no room for a stacked stairwell; using " + "motel-style exterior stairs"; + return anyAdded; +} + +bool validateVerticalAccess(const BuildingPlan &plan, std::string *error) +{ + if (!plan.valid() || plan.floors < 2) + return true; + std::string stairError; + if (validateStairwells(plan, &stairError)) + return true; + /* Exterior-stair fallback: every upper floor needs a gallery + * door (one exterior door side) marked as an exterior stair. */ + for (int y = 1; y < plan.floors; ++y) { + bool ok = false; + for (int z = 0; z < plan.cellsZ && !ok; ++z) { + for (int x = 0; x < plan.cellsX && !ok; ++x) { + const uint64_t c = plan.at(x, y, z); + if (!(c & PlanFlags::ExteriorStair)) + continue; + int doorCount = 0; + for (int s = 0; s < 4; ++s) + if (c & kSideDoor[s]) + ++doorCount; + if (doorCount == 1) + ok = true; + } + } + if (!ok) { + if (error) + *error = "floor " + std::to_string(y) + + " has no stair access (no stacked " + "stairwell, no exterior stair): " + + stairError; + return false; + } + } + return true; +} + +bool validateStairwells(const BuildingPlan &plan, std::string *error) +{ + auto fail = [&](const std::string &msg) { + if (error) + *error = msg; + return false; + }; + if (!plan.valid() || plan.floors < 2) + return true; + const std::vector rooms = + planStairwellRooms(plan); + if (rooms.empty()) + return fail("multi-floor layout has no stairwell room"); + + for (int y = 0; y < plan.floors; ++y) { + const StairwellRoomInstance *inst = nullptr; + for (const auto &r : rooms) { + if (r.floor != y) + continue; + if (inst) + return fail("floor " + std::to_string(y) + + " has more than one stairwell " + "room"); + inst = &r; + } + if (!inst) + return fail("floor " + std::to_string(y) + + " has no stairwell room"); + const StairwellRoomInstance &ref = rooms.front(); + if (inst->minX != ref.minX || inst->minZ != ref.minZ || + inst->maxX != ref.maxX || inst->maxZ != ref.maxZ) + return fail("stairwell room rect differs between " + "floors"); + } + + const StairwellRoomInstance &ref = rooms.front(); + const StairRun run = planStairRun(ref.minX, ref.minZ, ref.maxX, + ref.maxZ); + if (!run.valid) + return fail("stairwell room is too small for a " + "landing-plus-run flight"); + for (int y = 0; y + 1 < plan.floors; ++y) { + for (int z = run.z0; z <= run.z1; ++z) { + for (int x = run.x0; x <= run.x1; ++x) { + if (roomAt(plan, x, y, z) != ref.roomId) + return fail("stairwell run cell " + "escaped the room on " + "floor " + + std::to_string(y)); + if (!(plan.at(x, y, z) & + PlanFlags::StairWell)) + return fail("stairwell run cell is " + "not flagged on floor " + + std::to_string(y)); + } + } + } + for (int y = 0; y < plan.floors; ++y) { + bool hasDoor = false; + for (int z = ref.minZ; z <= ref.maxZ && !hasDoor; ++z) { + for (int x = ref.minX; x <= ref.maxX && !hasDoor; + ++x) { + if (roomAt(plan, x, y, z) != ref.roomId) + continue; + if (plan.at(x, y, z) & kInternalDoorBits) + hasDoor = true; + } + } + if (!hasDoor) + return fail("stairwell room on floor " + + std::to_string(y) + + " has no interior door"); + } + return true; +} + +} /* namespace LayoutLib */ diff --git a/src/features/editScene/layoutlib/Stairwell.hpp b/src/features/editScene/layoutlib/Stairwell.hpp new file mode 100644 index 0000000..cf496e4 --- /dev/null +++ b/src/features/editScene/layoutlib/Stairwell.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include +#include + +#include "BuildingPlan.hpp" + +/** + * Stacked stairwell rooms (demo-taxi phase 8.3). + * + * A multi-floor building needs a staircase that a character can + * actually use on every floor. Carving a run out of arbitrary rooms + * (phase 7.3a) leaves the flight top flush against a wall and cuts + * through upper-floor rooms, so the plan-level contract is: + * + * - every floor carries a stairwell ROOM with the SAME XZ rectangle; + * - inside it, the flight occupies ONE cell-wide band on the low cross + * axis, leaving one landing cell at each end of the long axis plus a + * walkway band beside the flight, so the top step-off always has + * floor to step onto and upper floors can walk from the arrival + * landing to the next flight; + * - only the flight cells (not the landings/walkway) carry the + * `StairWell` flag, on every floor that hosts a flight (all but the + * top floor); + * - each floor instance connects to a neighbouring room through an + * interior door placed on a landing/walkway cell (never beside the + * flight), so the upper-floor stairwell room is a normal room. + * + * `synthesizeStairwell` inserts (or completes) the rooms for a plan, + * `validateStairwells` checks the contract. Both are pure plan + * operations, headless-testable. + */ +namespace LayoutLib { + +/** Footprint of a flight inside a stairwell room rectangle (inclusive + * cell bounds). One cell wide on the low cross axis; one landing cell + * is kept at each end of the long axis. `valid` is false when the + * rectangle is too small (rooms narrower/shorter than 4 cells), in + * which case the legacy cell-flagging (whole room = flight) is kept. */ +struct StairRun { + bool valid = false; + bool alongX = true; + int x0 = 0; + int z0 = 0; + int x1 = 0; + int z1 = 0; +}; + +/** Derive the flight footprint for a room rectangle. */ +StairRun planStairRun(int minX, int minZ, int maxX, int maxZ); + +/** Minimum room length (cells) a stairwell room needs for a usable + * flight: one landing each end plus a 3-cell run. */ +constexpr int STAIRWELL_MIN_ROOM_LEN = 5; + +/** One materialized stairwell room instance (a room id on a floor). */ +struct StairwellRoomInstance { + int roomId = -1; + int floor = 0; + int minX = 0; + int minZ = 0; + int maxX = 0; /* inclusive */ + int maxZ = 0; /* inclusive */ +}; + +/** All stairwell room instances (roomTypes == "stairwell") found in + * plan.roomIds, ordered by floor then bounds. */ +std::vector +planStairwellRooms(const BuildingPlan &plan); + +/** Insert (or complete) stacked stairwell rooms in a multi-floor plan. + * + * - no stairwell room: claims a deterministic rectangle on every floor + * (staying inside the floor area of ALL floors, avoiding exterior + * door cells), walls it, flags the flight and adds one interior door + * per floor; + * - stairwell room on some floors only / with differing rects: the + * reference rect is re-claimed on the missing floors (and on floors + * whose stairwell rect differs), so every floor carries the same + * room. + * + * Returns true when the plan changed. `warning` receives a human + * readable note when nothing could be inserted (tiny footprint). */ +bool synthesizeStairwell(BuildingPlan &plan, std::string *warning); + +/** Fallback vertical access for a multi-floor plan that cannot host a + * stacked stairwell room (tiny footprints): give every upper floor a + * motel-style EXTERIOR stair, i.e. mark a boundary room cell with the + * `ExteriorStair` flag and exactly one exterior door on its facade + * (converting an existing window/wall opening when needed). Returns + * true when every upper floor has exterior access afterwards; `note` + * receives the human readable reason. */ +bool addExteriorStairAccess(BuildingPlan &plan, std::string *note); + +/** Vertical-access contract: a stacked stairwell passes, otherwise + * every upper floor must carry an exterior stair (motel-style). This + * is the check used by the layout evaluator before it falls back to + * the legacy carve, which only remains for degenerate shells. */ +bool validateVerticalAccess(const BuildingPlan &plan, std::string *error); + +/** Check the stacked-stairwell contract for a plan: + * - floors < 2: trivially valid; + * - exactly one stairwell room per floor, identical XZ rectangles; + * - every flight floor (all but the top) flags the derived run; + * - each floor instance has an interior door on a landing/walkway + * cell leading to a non-stairwell room. + * Returns false and fills `error` on the first violation. */ +bool validateStairwells(const BuildingPlan &plan, std::string *error); + +} /* namespace LayoutLib */ diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index eb80093..aa631ad 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -1061,10 +1061,14 @@ public: timeAccumulator += dt; if (debugDraw) cCollisionSteps = 4; - while (timeAccumulator >= fixedDeltaTime) { + int steps = 0; + static const int kMaxStepsPerUpdate = 20; + while (timeAccumulator >= fixedDeltaTime && + steps < kMaxStepsPerUpdate) { physics_system.Update(fixedDeltaTime, cCollisionSteps, &temp_allocator, &job_system); timeAccumulator -= fixedDeltaTime; + ++steps; } /* Always consume remaining time with a partial step so * physics never sits idle for a frame. This keeps character @@ -1099,7 +1103,14 @@ public: for (JPH::Character *ch : characters) { JPH::BodyID bID = ch->GetBodyID(); if (body_interface.IsAdded(bID)) { - ch->PostSimulation(0.1f); + /* Max separation to still count as standing on + * the floor: the character capsule's origin + * sits a few centimetres above the contact + * point (shape offset + solver slack), so a + * 0.1 m tolerance left IsSupported() false + * while visibly standing - breaking ground + * state (walk/fall animation, step-up). */ + ch->PostSimulation(0.2f); Ogre::SceneNode *node = id2node[bID]; if (node) node->_setDerivedPosition( diff --git a/src/features/editScene/roadlib/InteriorScene.cpp b/src/features/editScene/roadlib/InteriorScene.cpp index 830dcb5..be9c24f 100644 --- a/src/features/editScene/roadlib/InteriorScene.cpp +++ b/src/features/editScene/roadlib/InteriorScene.cpp @@ -96,6 +96,108 @@ void arrivalPosition(const LayoutLib::BuildingPlan &plan, const Entrance &e, y = (float)e.floor * plan.cellHeight + FLOOR_SURFACE_Y + 0.02f; } +nlohmann::json compositionRulesJson( + const LayoutLib::RoomCompositionRules &rules) +{ + nlohmann::json out = nlohmann::json::array(); + for (const auto &rule : rules) { + nlohmann::json rj; + rj["kind"] = + rule.kind == LayoutLib::FurnitureCompositionRule::Kind::Row ? + "row" : + "group"; + rj["tags"] = rule.tags; + rj["anchor"] = rule.anchor; + rj["rootRotation"] = rule.rootRotation; + rj["step"] = rule.step; + rj["skip"] = rule.skip; + rj["count"] = rule.count; + if (!rule.children.empty()) { + nlohmann::json children = nlohmann::json::array(); + for (const auto &child : rule.children) { + nlohmann::json cj; + cj["tags"] = child.tags; + cj["offsetX"] = child.offsetX; + cj["offsetZ"] = child.offsetZ; + cj["rotationOffset"] = child.rotationOffset; + children.push_back(std::move(cj)); + } + rj["children"] = std::move(children); + } + out.push_back(std::move(rj)); + } + return out; +} + +uint32_t furnitureSeed(const std::string &key) +{ + uint32_t h = 2166136261u; + for (unsigned char c : key) { + h ^= c; + h *= 16777619u; + } + return h; +} + +/* Room metadata entity for RoomLayoutSystem, mirroring + * TownGenSystem::createPlanRooms(): geometry stays in the cell flags; + * the room only drives furniture generation. */ +nlohmann::json roomEntityJson(const LayoutLib::BuildingPlan &plan, + const LayoutLib::PlanRoomBounds &rb, + const std::string &address, int id) +{ + const std::string &roomName = plan.roomNames[rb.roomId]; + const std::string roomType = rb.roomId < (int)plan.roomTypes.size() ? + plan.roomTypes[rb.roomId] : + std::string(); + + nlohmann::json e; + e["id"] = id; + e["name"] = { + { "name", "room " + roomName + + (rb.floor > 0 ? " f" + std::to_string(rb.floor) : + "") } + }; + + nlohmann::json rj; + rj["persistentId"] = "townroom-" + address + "-" + roomName + + "-" + std::to_string(rb.floor); + rj["minX"] = rb.minX; + rj["minY"] = rb.floor; + rj["minZ"] = rb.minZ; + rj["maxX"] = rb.maxX + 1; /* RoomComponent max is exclusive */ + rj["maxY"] = rb.floor + 1; + rj["maxZ"] = rb.maxZ + 1; + rj["roomType"] = roomType; + + /* Same furniture selection fallbacks as createPlanRooms(). */ + std::vector tags = + rb.roomId < (int)plan.roomFurnitureTags.size() ? + plan.roomFurnitureTags[rb.roomId] : + std::vector(); + if (tags.empty() && !roomType.empty()) + tags = { roomType }; + else if (tags.empty()) + tags = { roomName }; + rj["tags"] = tags; + rj["createFloor"] = false; + rj["createCeiling"] = false; + rj["createInteriorWalls"] = false; + rj["createWindows"] = false; + rj["fillRoomWithFurniture"] = !tags.empty(); + rj["furnitureSeed"] = furnitureSeed(address + "|" + roomName + "|" + + std::to_string(rb.floor)); + rj["furnitureYOffset"] = 0.05f; + if (rb.roomId < (int)plan.roomCompositions.size() && + !plan.roomCompositions[rb.roomId].empty()) + rj["furnitureCompositions"] = + compositionRulesJson(plan.roomCompositions[rb.roomId]); + rj["exits"] = { false, false, false, false }; + rj["connectedRoomIds"] = nlohmann::json::array(); + e["room"] = std::move(rj); + return e; +} + nlohmann::json lightEntity(int id, const Lighting &lighting) { nlohmann::json e; @@ -269,6 +371,7 @@ Output build(const Input &in) { "address", in.address }, { "planHash", out.planHash }, { "sourceScene", in.sourceScenePath }, + { "ambient", in.lighting.ambient }, }; nlohmann::json entities = nlohmann::json::array(); @@ -360,6 +463,18 @@ Output build(const Input &in) cg["furnitureCells"] = nlohmann::json::array(); gridEntity["cellGrid"] = cg; + + /* Room metadata children: the shared RoomLayoutSystem furnishes + * them exactly like node-authored / plan-derived rooms (same + * seeds and persistent ids as TownGenSystem::createPlanRooms). */ + nlohmann::json rooms = nlohmann::json::array(); + for (const LayoutLib::PlanRoomBounds &rb : + LayoutLib::planRoomBounds(*plan)) + rooms.push_back(roomEntityJson(*plan, rb, in.address, + nextId++)); + if (!rooms.empty()) + gridEntity["children"] = std::move(rooms); + entities.push_back(gridEntity); /* ---- Arrival markers: one just inside every entrance. ---- */ @@ -378,8 +493,20 @@ Output build(const Input &in) entities.push_back(marker); } - /* ---- Lighting (v1: one directional light). ---- */ + /* ---- Lighting (v1: one directional light + a dim upward one). + * Ceilings face down: the main down-pointing light leaves them + * black, so a second upward light makes the storey undersides + * readable (the interior material is lit, with no ambient). ---- */ entities.push_back(lightEntity(nextId++, in.lighting)); + { + nlohmann::json up = lightEntity(nextId++, in.lighting); + up["name"] = { { "name", "interior-light-ceiling" } }; + up["light"]["direction"] = { { "x", 0.0f }, + { "y", 1.0f }, + { "z", 0.0f } }; + up["light"]["intensity"] = 0.6f; + entities.push_back(up); + } doc["entities"] = entities; out.document = std::move(doc); diff --git a/src/features/editScene/roadlib/InteriorScene.hpp b/src/features/editScene/roadlib/InteriorScene.hpp index 70c5b5c..9983f64 100644 --- a/src/features/editScene/roadlib/InteriorScene.hpp +++ b/src/features/editScene/roadlib/InteriorScene.hpp @@ -52,6 +52,10 @@ struct Lighting { float dirY = -1.0f; float dirZ = 0.2f; float diffuse = 1.0f; + /** Scene ambient while inside: enclosed rooms get no sky + * contribution, so two directional lights alone leave every + * surface facing away from them pitch black. */ + float ambient = 0.5f; }; struct Input { diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index 5d5ccdf..3a3c02b 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -593,17 +593,6 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, meshData.entities.push_back(entity3d); } - // Internal windows - if (intWindowsTb.getVertices().size() >= 3) { - meshData.intWindowsMesh = baseName + "_intWindows"; - auto mesh = convertToMesh(meshData.intWindowsMesh, intWindowsTb, - materialName); - auto entity3d = - m_sceneMgr->createEntity(meshData.intWindowsMesh); - transform.node->attachObject(entity3d); - meshData.entities.push_back(entity3d); - } - // Phase 7.1: stair flights (and their collider via the regular // per-part mesh collider list) if (stairTb.getVertices().size() >= 3) { @@ -740,15 +729,12 @@ static void genPlane(float sizeX, float sizeY, const Ogre::Vector3 &normal, /* ---- Phase 7.1: CellGrid stairs ------------------------------------- */ -/* Comfort/traversal bounds for generated interior stairs. The tread - * (0.30) and the rise cap (0.18) stay inside the proven climbing - * envelope of the existing entrance stairs (rise 0.18 / tread 0.30, - * TownGenSystem::createStairs) and far inside Recast's limits - * (walkableClimb 1.0 m, walkableSlopeAngle 20 deg). 0.25 m risers - * were tried first and the Jolt character cannot mount them (the - * capsule stops dead at the first tread). */ -static const float STAIR_RISE_MAX = 0.18f; -static const float STAIR_TREAD = 0.30f; +/* Comfort/traversal bounds for generated interior stairs: rise + * 0.12-0.15 m and tread 0.23-0.27 m at walk speed (the Jolt capsule + * stops dead on 0.25 m risers; the entrance stairs' 0.18/0.30 was + * still a struggle at NPC walk speed). The public constants live on + * CellGridSystem so the acceptance probes can derive the first-step + * top. */ /* Slab-edge ring width: the ring both hides the backface see-through * of the cut floor/ceiling planes and doubles as the flush step-off * ledge at the opening rim. */ @@ -805,6 +791,13 @@ void CellGridSystem::buildFloorsAndCeilings( stairCells.insert(cell.getKey()); } + /* Phase 8.2: a standalone interior must be closed from above in + * EVERY cell: under the next storey, and also under the open sky + * where the upper floor does not cover the room (annexes, cut + * footprints). The layout flags Ceiling on the top floor only. + * Stair cells keep their ceiling open (the flight passes). */ + const bool interiorOnly = grid.interiorOnlyMode(); + for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); uint64_t flags = cell.flags; @@ -814,7 +807,6 @@ void CellGridSystem::buildFloorsAndCeilings( const bool holeBelow = stairCells.count( cellKey(cell.x, cell.y - 1, cell.z)) != 0; - // Floor: original sizeX=2.0f, sizeY=2.0f (half-sizes), scaled by hScale if ((flags & CellFlags::Floor) && !holeBelow) { genPlane(2.0f * hScale, 2.0f * hScale, @@ -822,12 +814,25 @@ void CellGridSystem::buildFloorsAndCeilings( Ogre::Vector3(0, floorY, 0), floorTb, origin); } - // Ceiling: original sizeX=2.0f, sizeY=2.0f (half-sizes), scaled by hScale - if ((flags & CellFlags::Ceiling) && !stairCell) { - genPlane(2.0f * hScale, 2.0f * hScale, - Ogre::Vector3::NEGATIVE_UNIT_Y, - Ogre::Vector3(0, ceilingY, 0), ceilingTb, - origin); + // Ceiling. InteriorOnly uses a thin slab: the single + // down-facing plane was backface-culled from below in some + // camera/material combinations (the room read as open sky), + // while a closed box always has a visible bottom face. + if (((flags & CellFlags::Ceiling) || interiorOnly) && + !stairCell) { + if (interiorOnly) { + genStairBox( + ceilingTb, + origin + Ogre::Vector3( + 0, ceilingY + 0.01f, 0), + Ogre::Vector3(2.0f * hScale, 0.02f, + 2.0f * hScale)); + } else { + genPlane(2.0f * hScale, 2.0f * hScale, + Ogre::Vector3::NEGATIVE_UNIT_Y, + Ogre::Vector3(0, ceilingY, 0), + ceilingTb, origin); + } } } } @@ -971,9 +976,9 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, /* ---- Flights. ---- */ auto planSteps = [&](float runLen, int &nSteps, float &rise, float &tread) { - nSteps = std::max(2, (int)std::ceil(ch / STAIR_RISE_MAX)); + nSteps = std::max(2, (int)std::ceil(ch / CellGridSystem::STAIR_RISE_MAX)); rise = ch / (float)nSteps; - tread = STAIR_TREAD; + tread = CellGridSystem::STAIR_TREAD; const float avail = runLen - 2.0f * STAIR_RING; if ((float)nSteps * tread > avail) { tread = avail / (float)nSteps; @@ -1242,9 +1247,9 @@ void CellGridSystem::buildStairs(const CellGridComponent &grid, const int alongCell = alongX ? cell.x : cell.z; int n = std::max( 2, (int)std::ceil((landY - groundY) / - STAIR_RISE_MAX)); + CellGridSystem::STAIR_RISE_MAX)); const float stepRise = (landY - groundY) / (float)n; - float tread = STAIR_TREAD; + float tread = CellGridSystem::STAIR_TREAD; float run = (float)(n - 1) * tread; const int availPos = alongCount - 1 - alongCell; const int availNeg = alongCell; @@ -1413,6 +1418,12 @@ void CellGridSystem::buildWalls(const CellGridComponent &grid, const bool shell = !grid.interiorOnlyMode(); const bool interior = !grid.exteriorOnlyMode(); + /* Phase 8.2: a standalone generated interior has no exterior + * scene to supply its perimeter, so interiorOnly renders the + * outer walls from the INSIDE. The mapped planes span the FULL + * cell width (unlike the internal walls, which are inset for + * corner pieces): adjacent cells' planes meet exactly, so no + * thin vertical gaps appear at cell boundaries. */ for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); uint64_t flags = cell.flags; @@ -1429,6 +1440,38 @@ void CellGridSystem::buildWalls(const CellGridComponent &grid, bit.offset, *bit.tb, origin); } } + + /* InteriorOnly: inward-facing, full-cell-width perimeter + * planes at the internal wall plane/height. */ + if (!shell) { + const float width = 2.0f * hScale; + const float yc = + solidIntHeight * 0.5f + solidIntOffset; + const float xin = -hScale + intWallOffset; + const float xout = hScale - intWallOffset; + const float zin = -hScale + intWallOffset; + const float zout = hScale - intWallOffset; + if (flags & CellFlags::WallXNeg) + genPlane(solidIntHeight, width, + Ogre::Vector3::UNIT_X, + Ogre::Vector3(xin, yc, 0), + intWallTb, origin); + if (flags & CellFlags::WallXPos) + genPlane(solidIntHeight, width, + Ogre::Vector3::NEGATIVE_UNIT_X, + Ogre::Vector3(xout, yc, 0), + intWallTb, origin); + if (flags & CellFlags::WallZPos) + genPlane(solidIntHeight, width, + Ogre::Vector3::NEGATIVE_UNIT_Z, + Ogre::Vector3(0, yc, zout), + intWallTb, origin); + if (flags & CellFlags::WallZNeg) + genPlane(solidIntHeight, width, + Ogre::Vector3::UNIT_Z, + Ogre::Vector3(0, yc, zin), + intWallTb, origin); + } } } @@ -1958,6 +2001,123 @@ void CellGridSystem::buildDoors(const CellGridComponent &grid, } } + /* Phase 8.2: interiorOnly renders the outer door surrounds + * from the INSIDE at the internal plane, full cell width, + * so the neighbouring wall/window cells meet without thin + * gaps. The outward panels above stay (invisible from + * inside, needed if the scene is ever seen from outside). */ + if (grid.interiorOnlyMode()) { + const float sideWidth = + (2.0f * hScale - doorWidth) * 0.5f; + const float moffset = + (2.0f * hScale - sideWidth) * 0.5f; + const float doorHeight = std::min( + doorHeightBase - solidIntOffset, + solidIntHeight); + const float doorTopHeight = solidIntHeight - doorHeight; + const float baseOffsetY = solidIntOffset; + const float innerXNeg = -halfCell + 0.1f; + const float innerXPos = halfCell - 0.1f; + const float innerZNeg = -halfCell + 0.1f; + const float innerZPos = halfCell - 0.1f; + if (cell.hasFlag(CellFlags::DoorXNeg)) { + genPlane(doorHeight, sideWidth, + Ogre::Vector3::UNIT_X, + Ogre::Vector3(innerXNeg, + baseOffsetY + + doorHeight / 2.0f, + -moffset), + intDoorsTb, origin); + genPlane(doorHeight, sideWidth, + Ogre::Vector3::UNIT_X, + Ogre::Vector3(innerXNeg, + baseOffsetY + + doorHeight / 2.0f, + moffset), + intDoorsTb, origin); + genPlane(doorTopHeight, 2.0f * hScale, + Ogre::Vector3::UNIT_X, + Ogre::Vector3( + innerXNeg, + baseOffsetY + doorHeight + + doorTopHeight / 2.0f, + 0), + intDoorsTb, origin); + } + if (cell.hasFlag(CellFlags::DoorXPos)) { + genPlane(doorHeight, sideWidth, + Ogre::Vector3::NEGATIVE_UNIT_X, + Ogre::Vector3(innerXPos, + baseOffsetY + + doorHeight / 2.0f, + -moffset), + intDoorsTb, origin); + genPlane(doorHeight, sideWidth, + Ogre::Vector3::NEGATIVE_UNIT_X, + Ogre::Vector3(innerXPos, + baseOffsetY + + doorHeight / 2.0f, + moffset), + intDoorsTb, origin); + genPlane(doorTopHeight, 2.0f * hScale, + Ogre::Vector3::NEGATIVE_UNIT_X, + Ogre::Vector3( + innerXPos, + baseOffsetY + doorHeight + + doorTopHeight / 2.0f, + 0), + intDoorsTb, origin); + } + if (cell.hasFlag(CellFlags::DoorZNeg)) { + genPlane(doorHeight, sideWidth, + Ogre::Vector3::UNIT_Z, + Ogre::Vector3(-moffset, + baseOffsetY + + doorHeight / 2.0f, + innerZNeg), + intDoorsTb, origin); + genPlane(doorHeight, sideWidth, + Ogre::Vector3::UNIT_Z, + Ogre::Vector3(moffset, + baseOffsetY + + doorHeight / 2.0f, + innerZNeg), + intDoorsTb, origin); + genPlane(doorTopHeight, 2.0f * hScale, + Ogre::Vector3::UNIT_Z, + Ogre::Vector3( + 0, + baseOffsetY + doorHeight + + doorTopHeight / 2.0f, + innerZNeg), + intDoorsTb, origin); + } + if (cell.hasFlag(CellFlags::DoorZPos)) { + genPlane(doorHeight, sideWidth, + Ogre::Vector3::NEGATIVE_UNIT_Z, + Ogre::Vector3(-moffset, + baseOffsetY + + doorHeight / 2.0f, + innerZPos), + intDoorsTb, origin); + genPlane(doorHeight, sideWidth, + Ogre::Vector3::NEGATIVE_UNIT_Z, + Ogre::Vector3(moffset, + baseOffsetY + + doorHeight / 2.0f, + innerZPos), + intDoorsTb, origin); + genPlane(doorTopHeight, 2.0f * hScale, + Ogre::Vector3::NEGATIVE_UNIT_Z, + Ogre::Vector3( + 0, + baseOffsetY + doorHeight + + doorTopHeight / 2.0f, + innerZPos), + intDoorsTb, origin); + } + } + // Internal doors - append directly to intDoorsTb // (F5: skipped entirely in exteriorOnly mode) if (!grid.exteriorOnlyMode() && @@ -2105,6 +2265,131 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); + uint64_t flags = cell.flags; + /* Phase 8.2 follow-up 2: interiorOnly renders the perimeter + * windows from the inside with the SAME surround the + * exterior builds (full-height side pieces plus + * sill/lintel strips around a window-sized opening), + * mirrored to the internal wall plane. Mapping the + * external flags onto the internal window FRAME pieces + * (the previous attempt) only emitted the trim, leaving + * the rest of the wall open to the void. */ + if (grid.interiorOnlyMode() && + (cell.hasFlag(CellFlags::WindowXNeg) || + cell.hasFlag(CellFlags::WindowXPos) || + cell.hasFlag(CellFlags::WindowZNeg) || + cell.hasFlag(CellFlags::WindowZPos))) { + const float solidIntHeight = + grid.cellHeight - 0.3f - solidIntOffset; + const float inner = halfCell - 0.1f; + const float sideWidth = + (2.0f * hScale - windowWidth) / 2.0f; + const float sideOff = + windowWidth / 2.0f + sideWidth / 2.0f; + const float sideY = + solidIntOffset + solidIntHeight / 2.0f; + const float bottomSize = + windowBottomOffset - solidIntOffset; + const float topSize = solidIntOffset + + solidIntHeight - + windowBottomOffset - + windowHeight; + const float topY = windowBottomOffset + + windowHeight + topSize / 2.0f; + const float botY = + solidIntOffset + bottomSize / 2.0f; + auto emit = [&](bool alongX, float sign) { + /* sign < 0: XNeg/ZNeg, normal +axis; + * sign > 0: XPos/ZPos, normal -axis. */ + const bool ax = sign < 0.0f; + const float plane = sign * inner; + const Ogre::Vector3 &n = + ax ? Ogre::Vector3::UNIT_X : + Ogre::Vector3::NEGATIVE_UNIT_X; + const Ogre::Vector3 &nz = + ax ? Ogre::Vector3::UNIT_Z : + Ogre::Vector3::NEGATIVE_UNIT_Z; + if (alongX) { /* window on an X side */ + genPlane(solidIntHeight, sideWidth, + n, + Ogre::Vector3(plane, sideY, + -sideOff), + intWindowsTb, origin); + genPlane(solidIntHeight, sideWidth, + n, + Ogre::Vector3(plane, sideY, + sideOff), + intWindowsTb, origin); + genPlane(topSize, sideWidth, n, + Ogre::Vector3(plane, topY, + -sideOff), + intWindowsTb, origin); + genPlane(topSize, sideWidth, n, + Ogre::Vector3(plane, topY, + sideOff), + intWindowsTb, origin); + genPlane(bottomSize, sideWidth, n, + Ogre::Vector3(plane, botY, + -sideOff), + intWindowsTb, origin); + genPlane(bottomSize, sideWidth, n, + Ogre::Vector3(plane, botY, + sideOff), + intWindowsTb, origin); + genPlane(topSize, windowWidth, n, + Ogre::Vector3(plane, topY, + 0), + intWindowsTb, origin); + genPlane(bottomSize, windowWidth, n, + Ogre::Vector3(plane, botY, + 0), + intWindowsTb, origin); + } else { /* window on a Z side */ + genPlane(solidIntHeight, sideWidth, + nz, + Ogre::Vector3(-sideOff, + sideY, plane), + intWindowsTb, origin); + genPlane(solidIntHeight, sideWidth, + nz, + Ogre::Vector3(sideOff, + sideY, plane), + intWindowsTb, origin); + genPlane(topSize, sideWidth, nz, + Ogre::Vector3(-sideOff, + topY, plane), + intWindowsTb, origin); + genPlane(topSize, sideWidth, nz, + Ogre::Vector3(sideOff, + topY, plane), + intWindowsTb, origin); + genPlane(bottomSize, sideWidth, nz, + Ogre::Vector3(-sideOff, + botY, plane), + intWindowsTb, origin); + genPlane(bottomSize, sideWidth, nz, + Ogre::Vector3(sideOff, + botY, plane), + intWindowsTb, origin); + genPlane(topSize, windowWidth, nz, + Ogre::Vector3(0, topY, + plane), + intWindowsTb, origin); + genPlane(bottomSize, windowWidth, nz, + Ogre::Vector3(0, botY, + plane), + intWindowsTb, origin); + } + }; + if (cell.hasFlag(CellFlags::WindowXNeg)) + emit(true, -1.0f); + if (cell.hasFlag(CellFlags::WindowXPos)) + emit(true, 1.0f); + if (cell.hasFlag(CellFlags::WindowZNeg)) + emit(false, -1.0f); + if (cell.hasFlag(CellFlags::WindowZPos)) + emit(false, 1.0f); + } // External windows - append directly to extWindowsTb // (F4: skipped entirely in interiorOnly mode) @@ -2413,10 +2698,10 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, // Internal windows - append directly to intWindowsTb // (F5: skipped entirely in exteriorOnly mode) if (!grid.exteriorOnlyMode() && - (cell.hasFlag(CellFlags::IntWindowXNeg) || - cell.hasFlag(CellFlags::IntWindowXPos) || - cell.hasFlag(CellFlags::IntWindowZNeg) || - cell.hasFlag(CellFlags::IntWindowZPos))) { + ((flags & CellFlags::IntWindowXNeg) || + (flags & CellFlags::IntWindowXPos) || + (flags & CellFlags::IntWindowZNeg) || + (flags & CellFlags::IntWindowZPos))) { float intWallOffset = 0.1f; // Internal cell width = 2.0 * hScale - 2 * intWallOffset (1.8f for cellSize=4.0) float internalCellWidth = @@ -2428,7 +2713,7 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, float topSize = grid.cellHeight - windowY - windowHeight / 2.0f - 0.3f; - if (cell.hasFlag(CellFlags::IntWindowXNeg)) { + if (flags & CellFlags::IntWindowXNeg) { genPlane( windowHeight, sideWidth, Ogre::Vector3::UNIT_X, @@ -2498,7 +2783,7 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, 0), intWindowsTb, origin); } - if (cell.hasFlag(CellFlags::IntWindowXPos)) { + if (flags & CellFlags::IntWindowXPos) { genPlane(windowHeight, sideWidth, Ogre::Vector3::NEGATIVE_UNIT_X, Ogre::Vector3(halfCell - intWallOffset, @@ -2563,7 +2848,7 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, 0), intWindowsTb, origin); } - if (cell.hasFlag(CellFlags::IntWindowZNeg)) { + if (flags & CellFlags::IntWindowZNeg) { genPlane(windowHeight, sideWidth, Ogre::Vector3::UNIT_Z, Ogre::Vector3(-windowWidth / 2 - @@ -2632,7 +2917,7 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, intWallOffset), intWindowsTb, origin); } - if (cell.hasFlag(CellFlags::IntWindowZPos)) { + if (flags & CellFlags::IntWindowZPos) { genPlane( windowHeight, sideWidth, Ogre::Vector3::NEGATIVE_UNIT_Z, @@ -2833,21 +3118,20 @@ void CellGridSystem::buildGlass(const CellGridComponent &grid, }; if (interiorOnly) { - if (cell.hasFlag(CellFlags::IntWindowXNeg) && - cell.hasFlag(CellFlags::WindowXNeg)) + /* Phase 8.2: external window flags get an inward + * frame (buildWindows) plus a pane at the inner + * plane, hiding the void outside. */ + if (cell.hasFlag(CellFlags::WindowXNeg)) addPane(Ogre::Vector3(-intPlane, windowCenterY, 0), true); - if (cell.hasFlag(CellFlags::IntWindowXPos) && - cell.hasFlag(CellFlags::WindowXPos)) + if (cell.hasFlag(CellFlags::WindowXPos)) addPane(Ogre::Vector3(intPlane, windowCenterY, 0), true); - if (cell.hasFlag(CellFlags::IntWindowZNeg) && - cell.hasFlag(CellFlags::WindowZNeg)) + if (cell.hasFlag(CellFlags::WindowZNeg)) addPane(Ogre::Vector3(0, windowCenterY, -intPlane), false); - if (cell.hasFlag(CellFlags::IntWindowZPos) && - cell.hasFlag(CellFlags::WindowZPos)) + if (cell.hasFlag(CellFlags::WindowZPos)) addPane(Ogre::Vector3(0, windowCenterY, intPlane), false); @@ -4238,11 +4522,27 @@ void CellGridSystem::buildPhysicsColliders(flecs::entity entity) void CellGridSystem::destroyPhysicsColliders(flecs::entity entity) { + auto removeParentBody = [&](flecs::entity parent) { + if (!parent.is_valid() || !parent.is_alive()) + return; + /* Destroy the Jolt body explicitly: entity.destruct() alone + * bypasses EditorPhysicsSystem's removal hook in old call + * paths, and a leaked static body from a previous build (or + * a previously visited interior, all built at the origin) + * becomes an invisible collider in the next scene. */ + if (parent.has() && m_physicsSystem) { + auto &rb = parent.get_mut(); + if (rb.bodyCreated) + m_physicsSystem->removeRigidBody(rb); + } + parent.destruct(); + }; + auto it = m_entityMeshes.find(entity.id()); if (it != m_entityMeshes.end() && it->second.physicsParentEntity.is_valid() && it->second.physicsParentEntity.is_alive()) { - it->second.physicsParentEntity.destruct(); + removeParentBody(it->second.physicsParentEntity); it->second.physicsParentEntity = flecs::entity::null(); } diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index a77f0f3..479b7ca 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -54,6 +54,12 @@ public: std::vector getFurniturePlacements(flecs::entity entity) const; + /* Stair traversal limits (phase 8.2 follow-up): the Jolt + * character needs shallow risers / short treads at walk speed. + * Exposed for the acceptance probes (first-step top, width). */ + static constexpr float STAIR_RISE_MAX = 0.14f; + static constexpr float STAIR_TREAD = 0.25f; + /** Stable identity and live runtime handle of one furniture POI * (phase 6.5.G). `id` is derived from gridUid + persistent * placement id + marker name, never from a transient flecs id. diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index a81c59c..6d41ad4 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -507,29 +507,29 @@ Ogre::String CharacterSlotSystem::resolveMesh(const Ogre::String &age, } } - /* outfitLevel 0 (nude): prefer a detailed layer-0 variant when the - * catalog carries one for this slot (demo-taxi phase 6.C.1). The - * plain layer-0 meshes are simplified composition bases meant to - * disappear under layer-1/2 garments; a "detailed-nude" tagged - * entry is the anatomically complete variant for characters - * wearing nothing. Slots without one fall through to the - * composition base below, so the variants can be authored one - * slot at a time. */ - if (outfitLevel <= 0) { - for (const auto &entry : slotEntries) { - if (entry.value("layer", 0) != 0) - continue; - bool detailed = false; - for (const auto &t : - entry.value("tags", nlohmann::json::array())) { - if (t.get() == "detailed-nude") { - detailed = true; - break; - } + /* No layer-1/2 garment resolved for this slot: prefer a detailed + * layer-0 variant when the catalog carries one (demo-taxi phase + * 6.C.1). The plain layer-0 meshes are simplified composition + * bases meant to disappear under layer-1/2 garments; a + * "detailed-nude" tagged entry is the anatomically complete + * variant for a slot that is effectively bare. This covers + * outfitLevel 0 as well as outfitLevel 1/2 records whose garment + * selections are empty or point at a missing catalog entry. + * Slots without one fall through to the composition base below, + * so the variants can be authored one slot at a time. */ + for (const auto &entry : slotEntries) { + if (entry.value("layer", 0) != 0) + continue; + bool detailed = false; + for (const auto &t : + entry.value("tags", nlohmann::json::array())) { + if (t.get() == "detailed-nude") { + detailed = true; + break; } - if (detailed) - return entry["mesh"].get(); } + if (detailed) + return entry["mesh"].get(); } /* Fallback to layer 0 (nude base) — prefer shortest name. diff --git a/src/features/editScene/systems/CharacterSystem.cpp b/src/features/editScene/systems/CharacterSystem.cpp index e92c141..13d7c45 100644 --- a/src/features/editScene/systems/CharacterSystem.cpp +++ b/src/features/editScene/systems/CharacterSystem.cpp @@ -340,6 +340,16 @@ void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc) ch->AddToPhysicsSystem(); + /* Vertical dynamics: game-feel gravity + CCD so a fast fall cannot + * tunnel through thin streamed terrain colliders. */ + JPH::PhysicsSystem *phys = m_physics->getPhysicsSystem(); + if (phys && cc.gravityScale != 1.0f) + m_physics->setGravityFactor(ch->GetBodyID(), + cc.gravityScale); + if (phys) + phys->GetBodyInterface().SetMotionQuality( + ch->GetBodyID(), JPH::EMotionQuality::LinearCast); + createHeadCollider(e, cc, transform); createChestCollider(e, cc); // Don't modify gravity factor - let buoyancy handle floating/sinking @@ -441,6 +451,75 @@ void CharacterSystem::enablePhysics(flecs::entity e) } } +void CharacterSystem::tryStepUp(flecs::entity e, CharacterComponent &cc, + CharacterState &state, + const Ogre::Vector3 &charPos, + const Ogre::Vector3 &nodePos) +{ + (void)e; + if (!m_physics || !state.character) + return; + Ogre::Vector3 dir(cc.linearVelocity.x, 0.0f, cc.linearVelocity.z); + if (dir.squaredLength() < 0.01f) + return; + dir.normalise(); + + const float probe = cc.radius + 0.18f; + Ogre::Vector3 hit; + JPH::BodyID hitBody; + + /* 1. Layer filter: only static world geometry is a "ledge". A + * character/vehicle in front must not be stepped onto. */ + const Ogre::Vector3 low = charPos + Ogre::Vector3(0, 0.05f, 0); + if (!m_physics->raycastQuery(low, low + dir * probe, hit, hitBody)) + return; + + /* 2. Above the step cap must be CLEAR: a wall/door/window is not a + * ledge (its top is too high or it continues upward). */ + const Ogre::Vector3 high = + charPos + Ogre::Vector3(0, cc.maxStepHeight + 0.05f, 0); + Ogre::Vector3 highHit; + JPH::BodyID highBody; + if (m_physics->raycastQuery(high, high + dir * probe, highHit, + highBody)) + return; + + /* 3. Find the ledge top just ahead. */ + const Ogre::Vector3 downFrom = + charPos + dir * (cc.radius + 0.12f) + + Ogre::Vector3(0, cc.maxStepHeight + 0.1f, 0); + Ogre::Vector3 ledge; + JPH::BodyID ledgeBody; + if (!m_physics->raycastQuery( + downFrom, + downFrom - Ogre::Vector3(0, cc.maxStepHeight + 0.25f, 0), + ledge, ledgeBody)) + return; + const float step = ledge.y - charPos.y; + if (step <= 0.02f || step > cc.maxStepHeight) + return; + + /* Lift capsule + node together (root-motion characters write the + * node over the capsule each frame). Kill downward velocity so the + * capsule does not settle back into the ledge face. */ + const Ogre::Vector3 lifted(charPos.x, charPos.y + step + 0.02f, + charPos.z); + state.character->SetPosition(JoltPhysics::convert(lifted)); + JPH::Vec3 vel = state.character->GetLinearVelocity(); + vel.SetY(0.0f); + state.character->SetLinearVelocity(vel); + if (state.sceneNode) + state.sceneNode->_setDerivedPosition( + Ogre::Vector3(nodePos.x, nodePos.y + step + 0.02f, + nodePos.z)); + cc.hasFloor = true; + + Ogre::LogManager::getSingleton().logMessage( + "CharacterSystem: step-up " + + Ogre::StringConverter::toString(step, 3) + " m at " + + Ogre::StringConverter::toString(charPos)); +} + void CharacterSystem::update(float deltaTime) { if (!m_initialized || !m_physics) @@ -518,8 +597,53 @@ void CharacterSystem::update(float deltaTime) } else { finalVel.SetY(currentVel.GetY()); } + /* Terminal velocity: keeps the per-step travel below + * the capsule diameter (no tunneling through thin + * colliders) while still falling fast enough to feel + * right. */ + if (finalVel.GetY() < -cc.maxFallSpeed) + finalVel.SetY(-cc.maxFallSpeed); state.character->SetLinearVelocity(finalVel); + /* Phase 8.4 controller-level step-up: a rigid capsule + * cannot mount a vertical curb even when it is only a + * few centimetres high. Compare the achieved + * horizontal progress with the requested velocity: + * stairs/slopes keep moving (the capsule climbs + * them), only a genuinely BLOCKED move triggers the + * ledge probe. */ + if (cc.maxStepHeight > 0.0f && + state.hasLastPosition && cc.isSupported) { + const float speed = std::sqrt( + cc.linearVelocity.x * + cc.linearVelocity.x + + cc.linearVelocity.z * + cc.linearVelocity.z); + if (speed > 0.2f) { + Ogre::Vector3 dir( + cc.linearVelocity.x, 0.0f, + cc.linearVelocity.z); + dir.normalise(); + Ogre::Vector3 actual = + charPos - state.lastPosition; + actual.y = 0.0f; + const float progressed = + actual.dotProduct(dir); + const float expected = + speed * deltaTime; + if (expected > 0.01f && + progressed < expected * 0.35f) { + tryStepUp(e, cc, state, + charPos, + nodePos); + charPos = + JoltPhysics::convert( + state.character + ->GetPosition()); + } + } + } + /* 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 @@ -609,5 +733,11 @@ void CharacterSystem::update(float deltaTime) cc.chestColliderBody, chestPos, chestRot, true); } + + /* Step-up bookkeeping for the NEXT frame: the position + * the physics actually reached (after any lift). */ + state.lastPosition = JoltPhysics::convert( + state.character->GetPosition()); + state.hasLastPosition = true; }); } diff --git a/src/features/editScene/systems/CharacterSystem.hpp b/src/features/editScene/systems/CharacterSystem.hpp index 8bcf6f0..90be19e 100644 --- a/src/features/editScene/systems/CharacterSystem.hpp +++ b/src/features/editScene/systems/CharacterSystem.hpp @@ -55,10 +55,22 @@ private: struct CharacterState { JPH::Character *character = nullptr; Ogre::SceneNode *sceneNode = nullptr; + /* Step-up assist (phase 8.4): previous frame position, used to + * tell a BLOCKED horizontal move (curb/wall) from a normal + * one (stairs, slopes - the capsule already climbs those). */ + Ogre::Vector3 lastPosition = Ogre::Vector3::ZERO; + bool hasLastPosition = false; }; void setupEntity(flecs::entity e, CharacterComponent &cc); void teardownEntity(flecs::entity e); + /** Lift a grounded character onto a static ledge <= maxStepHeight + * when the horizontal move is blocked. Lifts both the physics + * capsule and the scene node (root-motion characters sync the node + * back over the capsule). */ + void tryStepUp(flecs::entity e, CharacterComponent &cc, + CharacterState &state, const Ogre::Vector3 &charPos, + const Ogre::Vector3 &nodePos); JPH::ShapeRefC buildCharacterShape(flecs::entity e, CharacterComponent &cc); diff --git a/src/features/editScene/systems/EditorSunSystem.cpp b/src/features/editScene/systems/EditorSunSystem.cpp index 5de64e2..0b8b6c3 100644 --- a/src/features/editScene/systems/EditorSunSystem.cpp +++ b/src/features/editScene/systems/EditorSunSystem.cpp @@ -61,9 +61,11 @@ void EditorSunSystem::update(float deltaTime, Ogre::Camera *camera) lightDir.z = -std::sin(sunAngle) * std::sin(tiltRad); lightDir.normalise(); - // Update light direction by orienting the light node + // Update light direction by orienting the light node. + // Ogre derives a light's direction as -Z of the node + // orientation, so (0,0,-1) is the base vector. if (sun.lightNode) { - Ogre::Vector3 defaultDir(0, -1, 0); + Ogre::Vector3 defaultDir(0, 0, -1); Ogre::Quaternion rot = defaultDir.getRotationTo(lightDir); sun.lightNode->setOrientation(rot); } diff --git a/src/features/editScene/systems/InteriorNpcSystem.cpp b/src/features/editScene/systems/InteriorNpcSystem.cpp new file mode 100644 index 0000000..211b62b --- /dev/null +++ b/src/features/editScene/systems/InteriorNpcSystem.cpp @@ -0,0 +1,184 @@ +#include "InteriorNpcSystem.hpp" + +#include + +#include + +#include "../EditorApp.hpp" +#include "../components/AnimationTree.hpp" +#include "../components/CellGrid.hpp" +#include "../components/Character.hpp" +#include "../components/InteriorNpc.hpp" +#include "../components/Transform.hpp" +#include "../roadlib/AgeBands.hpp" +#include "../roadlib/TownLocations.hpp" +#include "AnimationTreeSystem.hpp" +#include "CellGridSystem.hpp" +#include "CharacterRegistry.hpp" +#include "NpcDetailSystem.hpp" +#include "NpcMotionSystem.hpp" +#include "NpcPopulationSystem.hpp" + +namespace { + +/* Keep a crowded workplace from filling the whole scene; v1 NPCs are + * static, so a dozen is already plenty of company. */ +constexpr size_t kMaxInteriorNpcs = 12; + +bool eligibleInBuilding(NpcPopulationSystem *pop, NpcMotionSystem *motion, + uint64_t id, const std::string &address) +{ + NpcMotionSystem::MotionInfo mi; + if (!motion->motionInfoOf(id, mi) || + mi.state != NpcMotionSystem::State::AtLocation) + return false; + const int area = pop->areaIndexOf(id); + const TownLocations::LocationSet *locs = + area >= 0 ? pop->areaLocations(area) : nullptr; + const TownLocations::NpcLocation *loc = + locs ? locs->findByName(mi.location) : nullptr; + if (!loc || loc->address != address) + return false; + /* Curb/parking are exterior anchors even inside the address set; + * only genuinely indoor kinds belong in the interior scene. */ + return loc->indoor || TownLocations::isIndoorKind(loc->kind); +} + +} // namespace + +InteriorNpcSystem::InteriorNpcSystem(flecs::world &world, EditorApp *app) + : m_world(world) + , m_app(app) +{ +} + +void InteriorNpcSystem::clear() +{ + m_spawned.clear(); + m_eligible = 0; +} + +int InteriorNpcSystem::populate(flecs::entity interiorGrid) +{ + clear(); + if (!interiorGrid.is_alive() || !interiorGrid.has()) + return 0; + const CellGridComponent &grid = interiorGrid.get(); + if (grid.gridUid.rfind("town-", 0) != 0) + return 0; /* authored interiors have no population address */ + const std::string address = grid.gridUid.substr(5); + if (address.empty()) + return 0; + + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + NpcMotionSystem *motion = NpcMotionSystem::getInstance(); + CharacterRegistry ®istry = CharacterRegistry::getSingleton(); + if (!pop || !motion) + return 0; + + /* Room anchors: prefer ordinary rooms over stairwells; the center + * cell is used, with a small deterministic spread for roommates. */ + struct RoomAnchor { + int x = 0; + int y = 0; + int z = 0; + }; + std::vector anchors; + interiorGrid.children([&](flecs::entity child) { + if (!child.has()) + return true; + const RoomComponent &room = child.get(); + if (room.roomType == "stairwell") + return true; + RoomAnchor a; + a.x = (room.minX + room.maxX - 1) / 2; + a.y = room.minY; + a.z = (room.minZ + room.maxZ - 1) / 2; + anchors.push_back(a); + return true; + }); + if (anchors.empty()) + return 0; + + std::vector eligible; + for (uint64_t id : pop->populationCharacterIds()) + if (eligibleInBuilding(pop, motion, id, address)) + eligible.push_back(id); + m_eligible = eligible.size(); + if (eligible.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "InteriorNpcSystem: no scheduled NPC is inside '" + + address + "' right now"); + return 0; + } + + NpcDetailSystem *detail = NpcDetailSystem::getInstance(); + AnimationTreeSystem *ats = m_app ? m_app->getAnimationTreeSystem() : + nullptr; + int spawned = 0; + for (size_t i = 0; i < eligible.size() && spawned < (int)kMaxInteriorNpcs; + ++i) { + const uint64_t id = eligible[i]; + if (registry.findSpawnedEntity(id).is_alive()) + continue; /* already materialized */ + flecs::entity npc = registry.spawnCharacter(id); + if (!npc.is_alive()) + continue; + + const RoomAnchor &anchor = anchors[i % anchors.size()]; + /* Spread roommates in a 3x3 pattern inside the centre cell + * so capsules/furniture do not stack them. */ + const int slot = (int)(i / anchors.size()); + const float ox = (float)(slot % 3 - 1) * 0.55f; + const float oz = (float)(slot / 3 % 3 - 1) * 0.55f; + Ogre::Vector3 pos((float)anchor.x * grid.cellSize + ox, + (float)anchor.y * grid.cellHeight + + CellGridSystem::floorSurfaceY() + + 0.1f, + (float)anchor.z * grid.cellSize + oz); + + auto &t = npc.get_mut(); + t.position = pos; + t.rotation = Ogre::Quaternion::IDENTITY; + t.applyToNode(); + + if (!npc.has()) { + CharacterComponent cc; + const CharacterRegistry::CharacterRecord *rec = + registry.findCharacter(id); + if (rec) { + const AgeBands::BodyMetrics bm = + AgeBands::bodyMetrics(rec->age, + rec->inlineSex); + cc.radius = bm.radius; + cc.height = bm.height; + cc.headRadius = bm.headRadius; + } + npc.set(cc); + } + if (!npc.has()) { + AnimationTreeComponent at; + at.treeName = detail ? + detail->animationTreeName : + Ogre::String("male1"); + at.useRootMotion = false; + npc.set(at); + } + if (ats) + ats->setState(npc, "locomotion", "idle"); + npc.add(); + Ogre::LogManager::getSingleton().logMessage( + "InteriorNpcSystem: " + std::to_string(id) + + " placed in room at " + std::to_string(anchor.x) + + "," + std::to_string(anchor.y) + "," + + std::to_string(anchor.z)); + m_spawned.push_back(npc.id()); + ++spawned; + } + + Ogre::LogManager::getSingleton().logMessage( + "InteriorNpcSystem: spawned " + std::to_string(spawned) + + " of " + std::to_string(eligible.size()) + + " scheduled NPC(s) inside '" + address + "'"); + return spawned; +} diff --git a/src/features/editScene/systems/InteriorNpcSystem.hpp b/src/features/editScene/systems/InteriorNpcSystem.hpp new file mode 100644 index 0000000..8c8d863 --- /dev/null +++ b/src/features/editScene/systems/InteriorNpcSystem.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +#include + +class EditorApp; + +/** + * Interior NPCs (demo-taxi phase 8.2 follow-up). + * + * The exterior detail tier deliberately does NOT spawn population NPCs + * whose symbolic state is at an indoor location (home/work/leisure), so + * generated interiors used to be empty. When an interior scene loads, + * this system spawns those NPCs at room centers (v1: no AI, no + * schedule movement, no interior pathing) and tags them with + * InteriorNpcTag. They live with the interior scene and are destroyed + * by the scene switch; the population registry keeps the exterior + * symbolic positions untouched (saves skip them). + */ +class InteriorNpcSystem { +public: + InteriorNpcSystem(flecs::world &world, EditorApp *app); + + /** Spawn the population NPCs whose current motion state is + * AtLocation at an indoor location owned by the building of + * @p interiorGrid (its gridUid is "town-
"). Returns the + * number spawned (capped). */ + int populate(flecs::entity interiorGrid); + + /** Forget the spawn bookkeeping (the entities themselves die with + * the scene). */ + void clear(); + + size_t spawnedCount() const + { + return m_spawned.size(); + } + /** Population NPCs whose state placed them inside the building + * during the last populate() (before the placement cap). */ + size_t eligibleCount() const + { + return m_eligible; + } + const std::vector &spawned() const + { + return m_spawned; + } + +private: + flecs::world &m_world; + EditorApp *m_app; + std::vector m_spawned; + size_t m_eligible = 0; +}; diff --git a/src/features/editScene/systems/LightSystem.cpp b/src/features/editScene/systems/LightSystem.cpp index b9529ff..0a6f60b 100644 --- a/src/features/editScene/systems/LightSystem.cpp +++ b/src/features/editScene/systems/LightSystem.cpp @@ -81,9 +81,13 @@ void EditorLightSystem::updateLight(flecs::entity entity, LightComponent& light, // Set direction for directional/spot lights by rotating the node if (light.lightType == LightComponent::LightType::Directional || light.lightType == LightComponent::LightType::Spotlight) { - // Convert direction to orientation - the light's default direction is typically (0,0,-1) or (0,-1,0) - // We need to orient the node to point in the desired direction - Ogre::Vector3 defaultDir(0, -1, 0); // Ogre lights typically point down by default + // Ogre derives the light direction as -Z of the node + // orientation (getDerivedDirection() == + // -orientation.zAxis()), so rotate (0,0,-1) onto the + // desired direction. Using (0,-1,0) as the base pointed + // every directional light somewhere else (e.g. an "up" + // light ended up horizontal, leaving ceilings unlit). + Ogre::Vector3 defaultDir(0, 0, -1); Ogre::Quaternion rot = defaultDir.getRotationTo(light.direction); transform.node->setOrientation(rot); } diff --git a/src/features/editScene/systems/NpcTrafficSystem.cpp b/src/features/editScene/systems/NpcTrafficSystem.cpp index d167505..105aefa 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.cpp +++ b/src/features/editScene/systems/NpcTrafficSystem.cpp @@ -26,6 +26,7 @@ #include "../components/AnimationTree.hpp" #include "../components/CharacterSpawner.hpp" #include "../components/EditorMarker.hpp" +#include "../components/EntityName.hpp" #include "../components/PathFollowing.hpp" #include "../components/PlayerController.hpp" #include "../components/RigidBody.hpp" @@ -1729,6 +1730,7 @@ void NpcTrafficSystem::seedArea(int areaIdx) NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); if (!pop) return; + GameClockSystem *clock = GameClockSystem::getInstance(); const TownLocations::LocationSet *locs = pop->areaLocations(areaIdx); if (!locs) return; @@ -1812,6 +1814,13 @@ void NpcTrafficSystem::seedArea(int areaIdx) } if (!driver) driver = adults[0]; + /* The owner walks for the rest of the theft day; from the + * next day the marker expires and seedArea() may replace + * the car. */ + auto stolen = m_stolenCars.find(driver); + if (stolen != m_stolenCars.end() && clock && + clock->getDay() <= stolen->second.day) + continue; /* One car per driver. */ if (m_cars.count(driver)) continue; @@ -1891,6 +1900,22 @@ void NpcTrafficSystem::update(float dt) m_graphVersion = graph->version; } + /* Phase 8.2 follow-up: carjacked cars return to their owners on + * the NEXT in-game day (the owner walks for the rest of the theft + * day). seedArea() adds only the missing fleet entries, so + * re-running it gives the affected owners their replacement car + * without touching the others. The marker stays until the car is + * actually in the fleet: after a load the town locations may + * still be streaming in, and the retry must not leave the owner + * permanently carless. */ + const uint32_t today = clock->getDay(); + std::set reseedAreas; + for (const auto &kv : m_stolenCars) + if (today > kv.second.day && kv.second.areaIdx >= 0) + reseedAreas.insert(kv.second.areaIdx); + for (int area : reseedAreas) + seedArea(area); + /* --- Seeding: areas with a location set get their fleet once. --- */ const std::vector ids = pop->populationCharacterIds(); std::set areas; @@ -1930,6 +1955,21 @@ void NpcTrafficSystem::update(float dt) ++it; } } + /* Carjacked cars whose driver left the population have no owner + * to replace: drop the marker with the record. An expired marker + * (replacement day reached) is dropped once the fleet has the + * owner's new car. */ + for (auto it = m_stolenCars.begin(); it != m_stolenCars.end();) { + const bool driverGone = + std::find(ids.begin(), ids.end(), it->first) == + ids.end(); + const bool replaced = today > it->second.day && + m_cars.count(it->first) != 0; + if (driverGone || replaced) + it = m_stolenCars.erase(it); + else + ++it; + } const double tabs = clock->getAbsoluteHours(); const float tod = clock->getTimeOfDayHours(); @@ -1999,6 +2039,33 @@ void NpcTrafficSystem::update(float dt) car.driverSpawner = flecs::entity(); } car.vehicle.remove(); + /* Phase 8.2 follow-up: the car stays where the player + * leaves it (VehicleStateStore snapshots it); the + * owner walks today and gets a fresh car next day. + * Rename it so the replacement fleet car (same driver) + * has a distinct identity. */ + if (car.vehicle.is_alive()) { + if (car.vehicle.has()) { + car.vehicle + .get_mut() + .name = + "stolen_" + + std::to_string((unsigned long long) + car.driverId) + + "_" + std::to_string(today) + + "_" + + std::to_string( + (unsigned long long) + m_stolenSerial++); + } + car.vehicle.add(); + VehicleOwnershipComponent owner; + owner.ownerId = car.driverId; + car.vehicle.set( + owner); + } + m_stolenCars[car.driverId] = + StolenCar{ car.areaIdx, today }; car.vehicle = flecs::entity(); /* the player owns it now */ m_cars.erase(kv.first); break; /* map changed; fresh iteration next frame */ @@ -2093,3 +2160,35 @@ void NpcTrafficSystem::postPhysicsUpdate() ats->setState(character, "locomotion", "driving"); } } + +nlohmann::json NpcTrafficSystem::serializeStolenCars() const +{ + nlohmann::json out = nlohmann::json::array(); + for (const auto &kv : m_stolenCars) + out.push_back({ { "driver", kv.first }, + { "area", kv.second.areaIdx }, + { "day", kv.second.day } }); + return out; +} + +void NpcTrafficSystem::deserializeStolenCars(const nlohmann::json &json) +{ + m_stolenCars.clear(); + if (!json.is_array()) + return; + for (const auto &e : json) { + const uint64_t driver = e.value("driver", 0ULL); + if (driver == 0) + continue; + StolenCar stolen; + stolen.areaIdx = e.value("area", -1); + stolen.day = e.value("day", 0u); + /* A fresh process starts the clock at day 0: a save made + * later in the session must still count the theft as + * "today" so the owner is carless today and replaced + * tomorrow, not after N more days. */ + if (GameClockSystem *clock = GameClockSystem::getInstance()) + stolen.day = std::min(stolen.day, clock->getDay()); + m_stolenCars[driver] = stolen; + } +} diff --git a/src/features/editScene/systems/NpcTrafficSystem.hpp b/src/features/editScene/systems/NpcTrafficSystem.hpp index 30959fd..5ea116d 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.hpp +++ b/src/features/editScene/systems/NpcTrafficSystem.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include "../components/AiDriver.hpp" @@ -181,6 +182,25 @@ public: out.push_back(kv.first); return out; } + /** Phase 8.2 follow-up: cars carjacked by the player. The owner + * walks for the rest of the theft day and the fleet assigns a + * fresh car on the next in-game day. (Serialize/deserialize parts + * of the save game; the old cars themselves live in + * VehicleStateStore.) */ + struct StolenCar { + int areaIdx = -1; + uint32_t day = 0; + }; + size_t stolenCarCount() const + { + return m_stolenCars.size(); + } + bool isDriverStolen(uint64_t driverId) const + { + return m_stolenCars.count(driverId) != 0; + } + nlohmann::json serializeStolenCars() const; + void deserializeStolenCars(const nlohmann::json &json); /** 6.6.6: probe a bay slot's slot->road terrain profile. * Memoized per area; curb spots always return true. */ bool slotReachable(int areaIdx, const TownLocations::NpcLocation &loc, @@ -461,6 +481,9 @@ private: flecs::world &m_world; EditorApp *m_editorApp; std::map m_cars; /* key: driver registry id */ + /** Carjacked cars by driver: owner walks today, new car next day. */ + std::map m_stolenCars; + uint64_t m_stolenSerial = 0; std::set m_seededAreas; /** Slot claims (6.6.3): claimKey -> driver registry id. A * per-frame pure snapshot rebuilt by rebuildClaims() — never diff --git a/src/features/editScene/systems/PhysicsSystem.cpp b/src/features/editScene/systems/PhysicsSystem.cpp index 72a7e55..f2848a8 100644 --- a/src/features/editScene/systems/PhysicsSystem.cpp +++ b/src/features/editScene/systems/PhysicsSystem.cpp @@ -13,7 +13,16 @@ EditorPhysicsSystem::EditorPhysicsSystem(flecs::world &world, { } -EditorPhysicsSystem::~EditorPhysicsSystem() = default; +EditorPhysicsSystem::~EditorPhysicsSystem() +{ + /* The body-removal observer captures `this`, and the ECS world + * outlives this system during app teardown (it destroys the + * remaining entities after the systems are gone). Remove the + * observer first, otherwise the dangling callback runs + * removeRigidBody() on a destroyed system. */ + if (m_bodyRemoveObserver.is_alive()) + m_bodyRemoveObserver.destruct(); +} void EditorPhysicsSystem::initialize() { @@ -27,6 +36,22 @@ void EditorPhysicsSystem::initialize() m_physics = std::make_unique(m_sceneMgr, cameraNode); + /* Body lifetime follows the component: remove the Jolt body on + * component removal and entity destruction alike (see the member + * comment). removeRigidBody() clears bodyCreated/bodyID, so an + * explicit removal followed by this observer is a no-op. */ + m_bodyRemoveObserver = m_world + .observer() + .event(flecs::OnRemove) + .each([this](flecs::entity, + RigidBodyComponent &rb) { + if (!m_physics || + !rb.bodyCreated || + rb.bodyID.IsInvalid()) + return; + removeRigidBody(rb); + }); + m_initialized = true; Ogre::LogManager::getSingleton().logMessage( "Physics system initialized"); @@ -41,9 +66,13 @@ void EditorPhysicsSystem::update(float deltaTime) * the whole load time (seconds), and without a clamp the wrapper * would run hundreds of catch-up steps in one frame - dynamic bodies * free-fall through not-yet-streamed terrain colliders or get - * launched sky-high by buoyancy impulses at depth. */ - if (deltaTime > 0.1f) - deltaTime = 0.1f; + * launched sky-high by buoyancy impulses at depth. + * + * 0.25 s (not 0.1) so heavy frames still advance physics in real + * time: the old 0.1 clamp made the whole game run in slow motion + * below 10 FPS (characters "fell" at a fraction of gravity). */ + if (deltaTime > 0.25f) + deltaTime = 0.25f; /* Sub-split timing for the benchmark CSV (a few timer reads). */ Ogre::Timer timer; diff --git a/src/features/editScene/systems/PhysicsSystem.hpp b/src/features/editScene/systems/PhysicsSystem.hpp index 9d23adf..10e5cd7 100644 --- a/src/features/editScene/systems/PhysicsSystem.hpp +++ b/src/features/editScene/systems/PhysicsSystem.hpp @@ -91,6 +91,16 @@ private: // Query for entities with RigidBody flecs::query m_rigidBodyQuery; + /* Destroys the Jolt body whenever a RigidBodyComponent is removed + * or its entity dies, no matter which code path destroys it + * (deleteEntity, clearScene, entity.destruct() in generated + * physics teardown, ...). Without this, every CellGrid rebuild + * and scene switch leaked static bodies into the Jolt world; + * generated interiors are all built at the world origin, so the + * stale colliders from previously visited interiors turned into + * invisible furniture/walls in the next one. */ + flecs::observer m_bodyRemoveObserver; + /* See getLastSyncMs()/getLastStepMs()/getLastBodiesMs(). */ float m_lastSyncMs = 0.0f; float m_lastStepMs = 0.0f; diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index f974077..1aaa082 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -18,6 +18,7 @@ #include "TownGenSystem.hpp" #include #include +#include #include #include #include @@ -2216,6 +2217,7 @@ void RoadSystem::syncRoadAtlasTextureUnit(const RoadConfig &cfg) Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); if (!mat) return; + bool attached = false; for (unsigned ti = 0; ti < mat->getNumTechniques(); ++ti) { Ogre::Technique *tech = mat->getTechnique(ti); for (unsigned pi = 0; pi < tech->getNumPasses(); ++pi) { @@ -2235,9 +2237,34 @@ void RoadSystem::syncRoadAtlasTextureUnit(const RoadConfig &cfg) "RoadAtlasTexture"); tu->setTextureAddressingMode( Ogre::TextureUnitState::TAM_WRAP); + attached = true; } } } + + /* A texture unit added AFTER the RTSS technique was generated is + * not sampled by the cached shader: the pass renders in the flat + * diffuse colour (the road read as plain grey after every scene + * switch, where ProceduralMaterialSystem restyles the material + * and wipes this unit, regenerating the shader without it). + * Rebuild the shader-based techniques whenever the unit was + * re-attached. */ + if (attached) { + Ogre::RTShader::ShaderGenerator *shaderGen = + Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (shaderGen) { + shaderGen->removeAllShaderBasedTechniques(*mat); + shaderGen->createShaderBasedTechnique( + *mat, + Ogre::MaterialManager::DEFAULT_SCHEME_NAME, + Ogre::RTShader::ShaderGenerator:: + DEFAULT_SCHEME_NAME); + shaderGen->validateMaterial( + Ogre::RTShader::ShaderGenerator:: + DEFAULT_SCHEME_NAME, + mat->getName()); + } + } } void RoadSystem::markNavMeshDirty() diff --git a/src/features/editScene/systems/RoomLayoutSystem.cpp b/src/features/editScene/systems/RoomLayoutSystem.cpp index 0ec103a..35fa96a 100644 --- a/src/features/editScene/systems/RoomLayoutSystem.cpp +++ b/src/features/editScene/systems/RoomLayoutSystem.cpp @@ -343,10 +343,17 @@ void RoomLayoutSystem::processRoom(flecs::entity roomEntity, RoomComponent& room } } - /* Phase 7.3: a stairwell room flags its cells; the CellGrid - * generator cuts the room's own ceiling and the floor of the - * cell above, so a staircase can pass between the floors. */ - if (room.roomType == "stairwell") { + /* Phase 7.3/8.3: a stairwell room flags its cells so the CellGrid + * generator cuts the floor/ceiling openings and builds the flight. + * Plan-derived rooms (all geometry verbs off) already carry the + * correct per-cell flight flags from the layout plan - the room + * rect also covers the landings and the side walkway, which must + * NOT be opened. Node-authored rooms keep the legacy "whole room + * is the flight" behaviour. */ + const bool geometryFromPlan = + !room.createFloor && !room.createCeiling && + !room.createInteriorWalls && !room.createWindows; + if (room.roomType == "stairwell" && !geometryFromPlan) { for (int z = room.minZ; z < room.maxZ; z++) { for (int x = room.minX; x < room.maxX; x++) { grid.getOrCreateCell(x, y, z).setFlag( diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 19e5637..60756be 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -37,6 +37,7 @@ #include "../systems/FurnitureLibrary.hpp" #include "../systems/PhysicsSystem.hpp" #include "../layoutlib/LayoutEval.hpp" +#include "../layoutlib/Stairwell.hpp" #include "../systems/GlobalStateStore.hpp" #include "../recast/TileCacheNavMesh.hpp" #include "../ui/NavigationPanel.hpp" @@ -7527,8 +7528,11 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) } tgs.clearLayoutPreview(); - /* Phase 7.3a: a multi-storey layout WITHOUT a stair room - * gets an automatic straight-run stairwell. */ + /* Phase 7.3a / 8.3: a multi-storey layout WITHOUT a stair + * room gets a stacked automatic stairwell ROOM - the same + * rectangle on every floor, the flight flagged on the + * flight floors, landings + side walkway left open and an + * interior door per floor. */ if (!tgs.previewLayout( "(shell 12 8 :floors 2)" "(floor 0 (door front center))", @@ -7548,7 +7552,7 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) .cells) if (c.flags & CellFlags::StairWell) ++stairCells; - if (stairCells < 4) + if (stairCells < 3) fail("multi-storey layout got no automatic " "stairwell"); cellSys->update(); @@ -7560,8 +7564,146 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) .isNull()) fail("automatic stairwell built no stair " "geometry"); + if (autoBuilding.is_alive()) { + const RoomComponent *lo = nullptr; + const RoomComponent *up = nullptr; + autoBuilding.children([&](flecs::entity ch) { + if (!ch.has()) + return true; + const auto &r = + ch.get(); + if (r.roomType != "stairwell") + return true; + if (r.minY == 0) + lo = &r; + if (r.minY == 1) + up = &r; + return true; + }); + if (!lo || !up) + fail("automatic stairwell has no " + "stacked stairwell rooms"); + else if (lo->minX != up->minX || + lo->minZ != up->minZ || + lo->maxX != up->maxX || + lo->maxZ != up->maxZ) + fail("automatic stairwell rooms differ " + "between floors"); + else if (autoBuilding + .get() + .cells.size() == 0) + fail("automatic stairwell grid empty"); + } } tgs.clearLayoutPreview(); + + /* Phase 8.3: a multi-floor shell too small for a stacked + * stairwell room (4x4 cells) must fall back to a motel-style + * EXTERIOR stair - no interior run is carved (it would top + * out against a room wall). */ + if (!tgs.previewLayout( + "(shell 8 8 :floors 2)", + nullptr, 0, 42, Ogre::Vector3(0, 0, 0), "full", + &dslError)) { + fail("exterior-stair preview failed: " + dslError); + } else { + flecs::entity tinyBuilding; + w->each([&](flecs::entity e, CellGridComponent &) { + if (!tinyBuilding.is_alive()) + tinyBuilding = e; + }); + if (!tinyBuilding.is_alive()) { + fail("exterior-stair preview created no grid"); + } else { + const CellGridComponent &tg = + tinyBuilding.get(); + int stairCells = 0, extCells = 0; + for (const Cell &c : tg.cells) { + if (c.flags & CellFlags::StairWell) + ++stairCells; + if (c.flags & CellFlags::ExteriorStair) + ++extCells; + } + if (stairCells != 0) + fail("tiny multi-floor shell still " + "carved an interior stair"); + if (extCells == 0) + fail("tiny multi-floor shell got no " + "exterior stair"); + cellSys->update(); + if (tinyBuilding.has()) { + Ogre::MeshPtr ext = + stairTestAttachedMesh( + tinyBuilding + .get() + .node, + "_stairs"); + int outside = 0; + float maxY = -1.0e9f; + stairTestScanMesh( + ext, + [&](const Ogre::Vector3 &v) { + maxY = std::max(maxY, + v.y); + if (std::fabs(v.x) > + 4.1f || + std::fabs(v.z) > + 4.1f) + ++outside; + }); + if (ext.isNull() || outside == 0 || + maxY < 3.9f) + fail("exterior stair fallback " + "built no outside " + "flight"); + } + } + } + tgs.clearLayoutPreview(); + + /* Phase 8.3 layout checker: the plan-level contract for the + * same source (stacked rooms, flagged flights, doors). */ + { + LayoutLib::LayoutBindings lbStair; + lbStair.lotW = 40.0f; + lbStair.lotD = 40.0f; + lbStair.availW = 35.0f; + lbStair.availD = 35.0f; + lbStair.minFp = 12.0f; + lbStair.maxFp = 16.0f; + lbStair.minFloors = 2; + lbStair.maxFloors = 2; + lbStair.density = 1.0f; + lbStair.densityPass = true; + std::mt19937 rngStair(7); + LayoutLib::LayoutEval evStair(lbStair, &rngStair, + nullptr); + LayoutLib::BuildingPlan pStair; + std::string errStair; + if (!evStair.evaluate( + "(shell 12 8 :floors 2)" + "(floor 0 (door front center))", + pStair, &errStair)) { + fail("stairwell contract plan eval: " + + errStair); + } else { + std::string verr; + if (!LayoutLib::validateStairwells(pStair, + &verr)) + fail("stairwell contract: " + verr); + const auto rooms = + LayoutLib::planStairwellRooms(pStair); + if (rooms.size() != 2) + fail("stacked stairwell room count " + + std::to_string(rooms.size()) + + " != 2"); + else if (rooms[0].minX != rooms[1].minX || + rooms[0].minZ != rooms[1].minZ || + rooms[0].maxX != rooms[1].maxX || + rooms[0].maxZ != rooms[1].maxZ) + fail("stacked stairwell rects differ"); + } + } } /* One 8 x 3 x 4 grid (32 x 4 m footprint, 4 m floors), every cell @@ -7860,7 +8002,10 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) { Ogre::MeshPtr grand = stairTestAttachedMesh(grandNode, "_stairs"); - const float firstTop = 0.2f + 4.0f / 23.0f; + const int stairSteps = (int)std::ceil( + 4.0f / CellGridSystem::STAIR_RISE_MAX); + const float firstTop = + 0.2f + 4.0f / (float)stairSteps; float zMin = 1.0e9f, zMax = -1.0e9f; stairTestScanMesh(grand, [&](const Ogre::Vector3 &p) { if (std::fabs(p.y - firstTop) < 0.01f) { @@ -7944,10 +8089,15 @@ bool TerrainTestRunner::testInteriorSceneLoad(EditorApp &app, LayoutLib::LayoutEval eval(lb, &rng, nullptr); LayoutLib::BuildingPlan plan; std::string err; + /* Phase 8.3: the stairwell room is a full-width strip so the + * stacked contract holds (one landing each end + a walkway). */ if (!eval.evaluate("(shell 12 8 :floors 2 :empty)" - "(floor 0 (room hall (rect 0 0 1 1))" + "(floor 0 (room stair (rect 0 0 1 0.5)" + " :type stairwell)" + " (room livingroom (rect 0 0.5 0.5 1))" + " (room bedroom (rect 0.5 0.5 1 1))" " (door front center))" - "(floor 1 (room up (rect 0 0 1 1))" + "(floor 1 (room livingroom (rect 0 0 1 1))" " (windows all))", plan, &err)) { Ogre::LogManager::getSingleton().logMessage( @@ -8019,10 +8169,71 @@ bool TerrainTestRunner::testInteriorSceneLoad(EditorApp &app, it->second.sceneSwitchPath != "exterior.json" || it->second.sceneSwitchPosZ != 20.0f) fail("interior exit door lost its pre-wired pose"); + int stairCells = 0; + for (const Cell &cell : g.cells) + if (cell.flags & CellFlags::StairWell) + ++stairCells; + if (stairCells != 4) + fail("interior stairwell cells missing"); + } + /* Phase 8.2: the document carries room metadata children so the + * shared RoomLayoutSystem furnishes the interior. */ + if (grid.is_alive()) { + int roomCount = 0; + bool stairRoom = false; + bool roomFlagsOk = true; + grid.children([&](flecs::entity child) { + if (!child.has()) + return true; + ++roomCount; + const auto &r = child.get(); + if (r.roomType == "stairwell") + stairRoom = true; + if (r.createFloor || r.createCeiling || + r.createInteriorWalls || r.createWindows || + !r.fillRoomWithFurniture || r.tags.empty()) + roomFlagsOk = false; + return true; + }); + if (roomCount != 5) + fail("interior room metadata count " + + std::to_string(roomCount) + " != 4"); + if (!stairRoom) + fail("interior stairwell room lost its type"); + if (!roomFlagsOk) + fail("interior room flags/tags wrong"); } if (!arrivalFound) fail("arrival marker not loaded"); + /* Phase 8.2: interiorOnly renders a complete standalone room set: + * inward perimeter walls, intermediate ceilings, inward window + * frames + glass and the stairwell flight. */ + if (grid.is_alive() && grid.has()) { + Ogre::SceneNode *node = grid.get().node; + auto meshVerts = [&](const char *suffix) { + Ogre::MeshPtr mesh = + stairTestAttachedMesh(node, suffix); + int count = 0; + stairTestScanMesh(mesh, [&](const Ogre::Vector3 &) { + ++count; + }); + return count; + }; + if (meshVerts("_intWall") <= 150) + fail("interior has no inward perimeter walls (" + + std::to_string(meshVerts("_intWall")) + " verts)"); + if (meshVerts("_ceiling") <= 120) + fail("interior has no intermediate ceilings (" + + std::to_string(meshVerts("_ceiling")) + " verts)"); + if (meshVerts("_intWindows") <= 0) + fail("interior has no inward window frames"); + if (meshVerts("_glass") <= 0) + fail("interior has no window glass"); + if (meshVerts("_stairs") <= 0) + fail("interior has no stair geometry"); + } + /* Teardown: collect-then-destruct (never inside each()), then let * the CellGridSystem sweep own the Ogre resources. */ for (flecs::entity e : loaded) diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 7c68d16..a18e093 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -1128,6 +1128,24 @@ TownGenSystem::findBuildingPlan(const std::string &gridUid) return it == g_buildingPlans.end() ? nullptr : &it->second; } +LayoutLib::BuildingPlan TownGenSystem::planFromGrid( + const LayoutLib::BuildingPlan &plan, const CellGridComponent &grid) +{ + LayoutLib::BuildingPlan out = plan; + if (out.cellsX != grid.width || out.cellsZ != grid.depth || + out.floors != grid.height) + return out; + out.cells.assign((size_t)out.cellsX * out.cellsZ * out.floors, 0); + for (const Cell &c : grid.cells) { + if (c.x < 0 || c.x >= out.cellsX || c.y < 0 || + c.y >= out.floors || c.z < 0 || c.z >= out.cellsZ) + continue; + out.cells[((size_t)c.y * out.cellsZ + c.z) * out.cellsX + + c.x] = c.flags; + } + return out; +} + void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid, const std::string &genMode) @@ -1208,8 +1226,14 @@ bool TownGenSystem::autoPlaceStairwell(CellGridComponent &grid, if (floors < 2) return false; for (const Cell &c : grid.cells) { - if (c.flags & CellFlags::StairWell) - return true; /* explicit stairwell room wins */ + /* An explicit stairwell room wins; a motel-style exterior + * stair also provides vertical access on tiny shells where + * no stacked stairwell room fits (phase 8.3) - carving an + * interior run on top of it only creates an unreachable + * flight against a room wall. */ + if (c.flags & (CellFlags::StairWell | + CellFlags::ExteriorStair)) + return true; } const int W = grid.width; @@ -1234,18 +1258,21 @@ bool TownGenSystem::autoPlaceStairwell(CellGridComponent &grid, }; Run best; - /* Deterministic scan: 4-cell runs first (tread 0.30 at 2 m cells, - * the verified climbable shape), 3-cell fallback; rows/columns in - * order. A run needs the boarding cell at the low end (ground - * floor) and the step-off cell at the high end (top floor). */ + /* Deterministic scan: 4-cell runs first, 3-cell fallback. Among + * the fitting runs prefer the one furthest from the facade (an + * interior row) and then the most central start: a run hugging a + * perimeter wall puts the whole stair against the wall, which is + * awkward to use and looks wrong. A run needs the boarding cell + * at the low end (ground floor) and the step-off cell at the high + * end (top floor). */ auto tryAxis = [&](bool alongX) { const int axisLen = alongX ? W : D; const int crossLen = alongX ? D : W; for (int wants = 4; wants >= 3 && best.start < 0; --wants) { if (wants + 2 > axisLen) continue; - for (int cross = 0; cross < crossLen && best.start < 0; - ++cross) { + int bestScore = -1000000; + for (int cross = 0; cross < crossLen; ++cross) { for (int start = 1; start + wants < axisLen; ++start) { bool ok = true; @@ -1276,11 +1303,22 @@ bool TownGenSystem::autoPlaceStairwell(CellGridComponent &grid, if (!ok || !hasFloor(bx, 0, bz) || !hasFloor(ex, floors - 1, ez)) continue; - best.alongX = alongX; - best.start = start; - best.cross = cross; - best.len = wants; - break; + const int crossMargin = std::min( + cross, crossLen - 1 - cross); + const int mid = + start + wants / 2; + const int axisMid = + (axisLen - 1) / 2; + const int score = + crossMargin * 1000 - + std::abs(mid - axisMid); + if (score > bestScore) { + bestScore = score; + best.alongX = alongX; + best.start = start; + best.cross = cross; + best.len = wants; + } } } } @@ -1563,8 +1601,11 @@ void TownGenSystem::createBuilding(flecs::entity container, CellGridComponent grid; fillBuildingCells(b, grid, genMode); - /* Phase 8.2: remember the plan for the generated interior. */ - registerBuildingPlan(grid.gridUid, b.plan); + /* Phase 8.2: remember the plan for the generated interior. The + * snapshot carries the grid's final flags, including the + * automatic stairwell carved by fillBuildingCells() (the layout + * plan itself has no stair cells for those). */ + registerBuildingPlan(grid.gridUid, planFromGrid(b.plan, grid)); /* cellToWorld() puts cell (x, y, z) at x*cellSize, so the shell * spans [0, width*cellSize] from the entity node — shift the diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index a7f7108..1f4f305 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -232,6 +232,13 @@ private: static void fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid, const std::string &genMode); + /** Snapshot the generated grid's cell flags (including the + * automatic stairwell carved by fillBuildingCells) into a plan + * copy: the interior generator must build the same cells, walls + * and stairs as the exterior. */ + static LayoutLib::BuildingPlan planFromGrid( + const LayoutLib::BuildingPlan &plan, + const CellGridComponent &grid); /** Phase 7.3a: carve an automatic stairwell into a multi-storey * plan that declares none (a `:type stairwell` room wins). Picks * a deterministic straight run along the longer usable axis (4 diff --git a/src/features/editScene/systems/VehicleControllerSystem.cpp b/src/features/editScene/systems/VehicleControllerSystem.cpp index 5549a37..9ccce4c 100644 --- a/src/features/editScene/systems/VehicleControllerSystem.cpp +++ b/src/features/editScene/systems/VehicleControllerSystem.cpp @@ -282,6 +282,9 @@ bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle, vehicle.add(); m_driving = true; + /* Phase 8.2 follow-up: the player has used this car; its pose is + * snapshotted across scene switches and saves. */ + vehicle.add(); m_vehicleId = vehicle.id(); m_characterId = character.id(); m_controllerId = controller.id(); diff --git a/src/features/editScene/systems/VehicleStateStore.cpp b/src/features/editScene/systems/VehicleStateStore.cpp new file mode 100644 index 0000000..01994dc --- /dev/null +++ b/src/features/editScene/systems/VehicleStateStore.cpp @@ -0,0 +1,251 @@ +#include "VehicleStateStore.hpp" + +#include + +#include + +#include "../components/EntityName.hpp" +#include "../components/PlayerController.hpp" +#include "../components/PrefabInstance.hpp" +#include "../components/RigidBody.hpp" +#include "../components/RuntimeMarker.hpp" +#include "../components/Transform.hpp" +#include "../components/Vehicle.hpp" +#include "RenderOriginSystem.hpp" +#include "SceneSerializer.hpp" +#include "physics/physics.h" + +VehicleStateStore &VehicleStateStore::getInstance() +{ + static VehicleStateStore s_instance; + return s_instance; +} + +void VehicleStateStore::clear() +{ + m_records.clear(); +} + +const VehicleStateStore::Record * +VehicleStateStore::find(const std::string &name) const +{ + for (const Record &r : m_records) + if (r.name == name) + return &r; + return nullptr; +} + +void VehicleStateStore::upsert(Record record) +{ + for (Record &r : m_records) { + if (r.name == record.name && r.scene == record.scene) { + r = std::move(record); + return; + } + } + m_records.push_back(std::move(record)); +} + +void VehicleStateStore::capture(flecs::world &world, + Ogre::SceneManager *sceneMgr, + const std::string &scene, + RenderOriginSystem *origin) +{ + if (scene.empty()) + return; + SceneSerializer serializer(world, sceneMgr); + world.each([&](flecs::entity e, VehicleComponent &, + TransformComponent &t) { + if (!e.has() && + !e.has()) + return; + if (!e.has()) + return; + const std::string name = + e.get().name; + if (name.empty()) + return; + + const Ogre::Vector3 render = + t.node ? t.node->_getDerivedPosition() : t.position; + const Ogre::Quaternion rotation = + t.node ? t.node->_getDerivedOrientation() : t.rotation; + JPH::DVec3 world(render.x, render.y, render.z); + if (origin) + world = origin->renderToWorld(render); + + Record r; + r.name = name; + r.scene = scene; + r.posX = world.GetX(); + r.posY = world.GetY(); + r.posZ = world.GetZ(); + r.rotation = rotation; + if (e.has()) + r.ownerId = + e.get().ownerId; + r.runtime = e.has(); + if (r.runtime) + r.entity = serializer.serializeEntity(e); + upsert(std::move(r)); + }); +} + +namespace { + +flecs::entity findVehicleByName(flecs::world &world, + const std::string &name) +{ + flecs::entity found; + world.each([&](flecs::entity e, EntityNameComponent &n) { + if (!found.is_alive() && n.name == name && + e.has()) + found = e; + }); + return found; +} + +void placeVehicle(flecs::entity entity, const VehicleStateStore::Record &r, + RenderOriginSystem *origin, JoltPhysicsWrapper *physics) +{ + if (!entity.has()) + return; + const Ogre::Vector3 render = + origin ? origin->worldToRender(r.posX, r.posY, r.posZ) : + Ogre::Vector3((float)r.posX, (float)r.posY, + (float)r.posZ); + auto &t = entity.get_mut(); + t.position = render; + t.rotation = r.rotation; + t.worldX = r.posX; + t.worldY = r.posY; + t.worldZ = r.posZ; + t.hasWorldPosition = true; + t.applyToNode(); + + if (entity.has() && physics) { + auto &rb = entity.get_mut(); + if (rb.bodyCreated) { + physics->setPosition(rb.bodyID, + JPH::RVec3(r.posX, r.posY, + r.posZ)); + physics->setRotation(rb.bodyID, r.rotation); + physics->setVelocities(rb.bodyID, + Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + } + } + /* A restored car was left parked: engine inputs off, handbrake + * on, so it holds the saved pose until the player takes it. */ + if (entity.has()) { + auto &vc = entity.get_mut(); + vc.inputForward = 0.0f; + vc.inputRight = 0.0f; + vc.inputBrake = 0.0f; + vc.inputHandBrake = 1.0f; + } +} + +} // namespace + +int VehicleStateStore::apply(flecs::world &world, + Ogre::SceneManager *sceneMgr, + const std::string &scene, + RenderOriginSystem *origin, + JoltPhysicsWrapper *physics) +{ + if (scene.empty()) + return 0; + int applied = 0; + for (const Record &r : m_records) { + if (r.scene != scene || r.name.empty()) + continue; + flecs::entity entity = findVehicleByName(world, r.name); + if (!entity.is_alive() && r.runtime && + !r.entity.is_null()) { + /* The carjacked traffic car was a runtime entity: the + * fresh scene has no scene-file entity, so recreate + * it from the captured component tree. */ + entity = world.entity(); + entity.add(); + SceneSerializer serializer(world, sceneMgr); + serializer.deserializeEntityComponents( + entity, r.entity, flecs::entity::null(), + nullptr, true, true, false); + /* The component tree is already materialized; a later + * resolveInstances() must not instantiate it twice. */ + if (entity.has()) + entity.get_mut() + .instantiated = true; + Ogre::LogManager::getSingleton().logMessage( + "VehicleStateStore: restored runtime vehicle '" + + r.name + "'"); + } + if (!entity.is_alive() || !entity.has()) + continue; + placeVehicle(entity, r, origin, physics); + entity.add(); + if (r.ownerId != 0) { + VehicleOwnershipComponent owner; + owner.ownerId = r.ownerId; + entity.set(owner); + } + ++applied; + } + return applied; +} + +nlohmann::json VehicleStateStore::serialize() const +{ + nlohmann::json out; + out["version"] = 1; + nlohmann::json records = nlohmann::json::array(); + for (const Record &r : m_records) { + nlohmann::json rj; + rj["name"] = r.name; + rj["scene"] = r.scene; + rj["pos"] = { r.posX, r.posY, r.posZ }; + rj["rot"] = { r.rotation.w, r.rotation.x, + r.rotation.y, r.rotation.z }; + rj["owner"] = r.ownerId; + rj["runtime"] = r.runtime; + if (!r.entity.is_null()) + rj["entity"] = r.entity; + records.push_back(std::move(rj)); + } + out["records"] = std::move(records); + return out; +} + +void VehicleStateStore::deserialize(const nlohmann::json &json) +{ + clear(); + if (!json.is_object() || !json.contains("records") || + !json["records"].is_array()) + return; + for (const auto &rj : json["records"]) { + Record r; + r.name = rj.value("name", ""); + r.scene = rj.value("scene", ""); + if (r.name.empty() || r.scene.empty()) + continue; + if (rj.contains("pos") && rj["pos"].is_array() && + rj["pos"].size() >= 3) { + r.posX = rj["pos"][0]; + r.posY = rj["pos"][1]; + r.posZ = rj["pos"][2]; + } + if (rj.contains("rot") && rj["rot"].is_array() && + rj["rot"].size() >= 4) + r.rotation = Ogre::Quaternion( + rj["rot"][0].get(), + rj["rot"][1].get(), + rj["rot"][2].get(), + rj["rot"][3].get()); + r.ownerId = rj.value("owner", 0ULL); + r.runtime = rj.value("runtime", false); + if (rj.contains("entity")) + r.entity = rj["entity"]; + upsert(std::move(r)); + } +} diff --git a/src/features/editScene/systems/VehicleStateStore.hpp b/src/features/editScene/systems/VehicleStateStore.hpp new file mode 100644 index 0000000..365a2d7 --- /dev/null +++ b/src/features/editScene/systems/VehicleStateStore.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace Ogre +{ +class SceneManager; +} +class JoltPhysicsWrapper; +class RenderOriginSystem; + +/** + * Player-touched vehicle snapshots (phase 8.2 follow-up). + * + * A scene switch destroys every scene entity, and a save reload re-reads + * the base scene file, so authored cars would snap back to their + * authored poses. This store keeps the last observed world pose of + * every vehicle the player has entered (tagged with + * PlayerTouchedVehicleTag by VehicleControllerSystem / NpcTrafficSystem), + * plus the ownership tag for carjacked traffic cars, and re-applies them + * whenever the owning scene loads. Runtime cars (the carjacked traffic + * vehicles) also carry their serialized entity so they can be re-created + * in a freshly loaded scene. + * + * The store is session state: it is serialized into save games and + * restored on load; startNewGame() clears it. + */ +class VehicleStateStore +{ +public: + struct Record { + std::string name; + /** Base scene path the vehicle belongs to. */ + std::string scene; + /** Authoritative world-space pose. */ + double posX = 0.0; + double posY = 0.0; + double posZ = 0.0; + Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY; + /** Registry id of the NPC owner (0 = unowned/authored). */ + uint64_t ownerId = 0; + /** Runtime entity: re-created from `entity` on scene load. */ + bool runtime = false; + nlohmann::json entity; + }; + + static VehicleStateStore &getInstance(); + + void clear(); + bool empty() const + { + return m_records.empty(); + } + size_t recordCount() const + { + return m_records.size(); + } + const std::vector &records() const + { + return m_records; + } + const Record *find(const std::string &name) const; + + /** Snapshot every player-touched vehicle of @p scene from the live + * world. Runtime vehicles also capture their serialized entity. */ + void capture(flecs::world &world, Ogre::SceneManager *sceneMgr, + const std::string &scene, RenderOriginSystem *origin); + + /** Apply the records of @p scene: named scene vehicles get the saved + * world pose, runtime vehicles are re-created from their entity + * JSON when missing. Returns the number of applied records. */ + int apply(flecs::world &world, Ogre::SceneManager *sceneMgr, + const std::string &scene, RenderOriginSystem *origin, + JoltPhysicsWrapper *physics); + + nlohmann::json serialize() const; + void deserialize(const nlohmann::json &json); + +private: + std::vector m_records; + + void upsert(Record record); +}; diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index ebe6bc7..998eb6a 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -34,6 +34,7 @@ #include "components/RoadGraph.hpp" #include "layoutlib/LayoutEval.hpp" +#include "layoutlib/Stairwell.hpp" #include "roadlib/RoadGeometryLib.hpp" #include "roadlib/RoadNetworkGen.hpp" #include "roadlib/RoadPath.hpp" @@ -4207,6 +4208,175 @@ static bool runTownGenProbe() } } + /* Phase 8.3: a multi-floor shell that cannot host a stacked + * stairwell room (4x4 cells here) must fall back to + * motel-style exterior stairs, not to an interior carve. */ + { + BuildingPlan pTiny; + LayoutEval evTiny(lb, &rngDsl, nullptr); + if (!evTiny.evaluate("(shell 8 8 :floors 2)", pTiny, + &derr)) { + printf(" *** exterior-stair fallback: eval " + "failed '%s' ***\n", derr.c_str()); + ok = false; + } else { + int extCells = 0, stairCells = 0, doorOk = 0; + for (int y = 1; y < pTiny.floors; ++y) { + for (int z = 0; z < pTiny.cellsZ; ++z) { + for (int x = 0; + x < pTiny.cellsX; ++x) { + const uint64_t c = + pTiny.at(x, y, z); + if (c & + PlanFlags::ExteriorStair) { + ++extCells; + int doors = 0; + if (c & PlanFlags::DoorZPos) + ++doors; + if (c & PlanFlags::DoorZNeg) + ++doors; + if (c & PlanFlags::DoorXNeg) + ++doors; + if (c & PlanFlags::DoorXPos) + ++doors; + if (doors == 1) + ++doorOk; + } + } + } + } + for (uint64_t f : pTiny.cells) + if (f & PlanFlags::StairWell) + ++stairCells; + std::string verr; + if (extCells == 0 || doorOk != extCells || + stairCells != 0) { + printf(" *** exterior-stair fallback: " + "ext %d doors %d stair %d ***\n", + extCells, doorOk, stairCells); + ok = false; + } + if (!validateVerticalAccess(pTiny, &verr)) { + printf(" *** vertical access: %s ***\n", + verr.c_str()); + ok = false; + } + if (validateStairwells(pTiny, nullptr)) { + printf(" *** tiny shell reported a " + "stacked stairwell ***\n"); + ok = false; + } + } + } + + /* Phase 8.3: layout checker - every multi-floor plan that can + * host the stacked stairwell room must have one: identical + * room rects on every floor, the flight flagged on each + * flight floor and an interior door per floor. Tiny + * footprints that cannot fit a landing-plus-run room keep + * the legacy carved run (warning only). */ + { + auto checkStairContract = [&](const BuildingPlan &p, + const char *what) { + if (p.floors < 2) + return; + std::string verr; + if (validateStairwells(p, &verr)) + return; + /* No room at all: the footprint could not + * host one on every floor (the synthesis + * falls back to the legacy carve), or a + * compact legacy stair room is too small + * for a landing-plus-run flight. Both are + * allowed; a REAL stairwell room that + * violates the contract is not. */ + const auto rooms = planStairwellRooms(p); + if (rooms.empty()) + return; + const StairRun run = planStairRun( + rooms.front().minX, + rooms.front().minZ, + rooms.front().maxX, + rooms.front().maxZ); + if (!run.valid) + return; + printf(" *** stairwell contract " + "'%s': %s ***\n", + what, verr.c_str()); + ok = false; + }; + BuildingPlan pTpl2; + std::mt19937 rngTpl2(3); + LayoutEval evTpl2(lb, &rngTpl2, nullptr); + if (evTpl2.evaluate( + LayoutLibrary::defaultTemplateSource(), + pTpl2, &derr)) + checkStairContract(pTpl2, "default template"); + for (const LayoutLibrary::LayoutExample &ex : + LayoutLibrary::examples()) { + BuildingPlan pEx2; + std::mt19937 rngEx2(5); + LayoutEval evEx2(lb, &rngEx2, nullptr); + if (evEx2.evaluate(ex.source, pEx2, &derr)) + checkStairContract(pEx2, ex.title); + } + /* The shipped demo-town layouts are the actual content: + * every multi-floor one must satisfy the contract - on + * the full-size lot AND on tight lots where the shell + * clamps to the setback rect (the stair rooms are + * full-width strips precisely so they survive this). */ + LayoutLibrary townLib; + const char *townPath = nullptr; + if (std::ifstream("town_layouts.txt").good()) + townPath = "town_layouts.txt"; + else if (std::ifstream( + "demos/demo-taxi/town_layouts.txt") + .good()) + townPath = + "demos/demo-taxi/town_layouts.txt"; + static const char *townNames[3] = { + "two-floor-house", "roadside-motel", + "lobby-hall" + }; + static const float townAvail[3] = { 35.0f, 14.0f, + 10.0f }; + if (townPath && + townLib.loadFile(townPath, &derr)) { + for (const char *tn : townNames) { + for (float avail : townAvail) { + LayoutBindings lbT = lb; + lbT.availW = avail; + lbT.availD = avail; + BuildingPlan pTown; + std::mt19937 rngTown(11); + LayoutEval evTown(lbT, &rngTown, + &townLib); + if (!evTown.evaluate(tn, + pTown, + &derr)) { + printf(" *** stairwell " + "layout eval: " + "%s ***\n", + derr.c_str()); + ok = false; + continue; + } + std::string verr; + if (!validateStairwells( + pTown, &verr)) { + printf(" *** stairwell " + "contract '%s'" + " avail %.0f: " + "%s ***\n", + tn, avail, + verr.c_str()); + ok = false; + } + } + } + } + } + /* Errors: unbalanced source, unknown name, double shell. */ BuildingPlan pBad; LayoutEval evBad(lb, &rngDsl, nullptr); @@ -4466,20 +4636,20 @@ static bool runTownGenProbe() std::vector warnsStair; LayoutEval evStair(lb, &rngDsl, nullptr); if (!evStair.evaluate( - "(shell 8 8 :floors 2 :empty)" - "(floor 0 (room sw (rect 0 0 0.5 1)" + "(shell 12 8 :floors 2 :empty)" + "(floor 0 (room sw (rect 0 0 1 0.25)" " :type stairwell)" - " (room hall (rect 0.5 0 1 1)))" - "(floor 1 (room landing (rect 0 0 1 1)))", + " (room hall (rect 0 0.25 1 1)))" + "(floor 1 (room landing (rect 0 0.25 1 1)))", pStair, &derr, &warnsStair)) { printf(" *** dsl stairwell rooms-first: eval " "failed '%s' ***\n", derr.c_str()); ok = false; } else { - const uint64_t swA = pStair.at(0, 0, 0); - const uint64_t swB = pStair.at(1, 0, 3); - const uint64_t hall = pStair.at(2, 0, 0); - const uint64_t above = pStair.at(0, 1, 0); + const uint64_t swA = pStair.at(1, 0, 0); + const uint64_t swB = pStair.at(4, 0, 0); + const uint64_t hall = pStair.at(2, 0, 2); + const uint64_t above = pStair.at(1, 1, 0); if (!(swA & PlanFlags::StairWell) || !(swB & PlanFlags::StairWell) || (hall & PlanFlags::StairWell) || @@ -4500,16 +4670,16 @@ static bool runTownGenProbe() BuildingPlan pStairFilled; LayoutEval evStairFilled(lb, &rngDsl, nullptr); if (!evStairFilled.evaluate( - "(shell 8 8 :floors 2)" - "(floor 0 (room sw (rect 0 0 0.5 1)" + "(shell 12 8 :floors 2)" + "(floor 0 (room sw (rect 0 0 1 0.25)" " :type stairwell))", pStairFilled, &derr)) { printf(" *** dsl stairwell filled: eval failed " "'%s' ***\n", derr.c_str()); ok = false; } else { - const uint64_t sw = pStairFilled.at(1, 0, 2); - const uint64_t other = pStairFilled.at(3, 0, 2); + const uint64_t sw = pStairFilled.at(1, 0, 0); + const uint64_t other = pStairFilled.at(1, 0, 2); if (!(sw & PlanFlags::StairWell) || (other & PlanFlags::StairWell) || !(sw & PlanFlags::Floor)) { @@ -4613,7 +4783,7 @@ static bool runTownGenProbe() doc.value("version", "") == "1.0"; size_t gridCount = 0, markerCount = 0, - lightCount = 0; + lightCount = 0, ceilingLightCount = 0; float arrivalZ = 1.0e9f; const nlohmann::json *cg = nullptr; for (const auto &ent : doc["entities"]) { @@ -4623,8 +4793,11 @@ static bool runTownGenProbe() ++gridCount; cg = &ent["cellGrid"]; } - if (ent.contains("light")) + if (ent.contains("light")) { ++lightCount; + if (n == "interior-light-ceiling") + ++ceilingLightCount; + } if (n.rfind("arrival:", 0) == 0) { ++markerCount; if (n == "arrival:" + k0) @@ -4636,8 +4809,13 @@ static bool runTownGenProbe() } } docOk = docOk && gridCount == 1 && - markerCount == 2 && lightCount == 1 && - cg != nullptr; + markerCount == 2 && lightCount == 2 && + ceilingLightCount == 1 && + cg != nullptr && + doc.value("interior", + nlohmann::json()) + .value("ambient", 0.0) > + 0.3; if (docOk) { const nlohmann::json &g = *cg; docOk = g.value("generationMode", "") == @@ -4656,6 +4834,96 @@ static bool runTownGenProbe() g["doorConfigs"].contains(k0) && g["doorConfigs"].contains(k1); } + /* Phase 8.2: room metadata children drive the + * shared RoomLayoutSystem furniture pass. */ + if (docOk) { + const auto &ents = doc["entities"]; + /* Phase 8.3: the layout got a stacked + * stairwell room, so 4 room instances + * (hall + up + stair f0 + stair f1). */ + docOk = ents[0].contains("children") && + ents[0]["children"].size() == 4; + const nlohmann::json *hall = nullptr; + const nlohmann::json *up = nullptr; + const nlohmann::json *stair0 = nullptr; + const nlohmann::json *stair1 = nullptr; + if (docOk) + for (const auto &re : + ents[0]["children"]) { + const std::string n = + re["name"] + ["name"]; + if (n == "room hall") + hall = &re; + if (n == "room up f1") + up = &re; + if (n == + "room stair") + stair0 = &re; + if (n == + "room stair f1") + stair1 = &re; + } + docOk = docOk && hall && up && + stair0 && stair1 && + (*stair0)["room"].value( + "roomType", "") == + "stairwell" && + (*stair1)["room"].value( + "roomType", "") == + "stairwell"; + if (docOk) { + const auto &r0 = + (*stair0)["room"]; + const auto &r1 = + (*stair1)["room"]; + docOk = r0["minX"] == + r1["minX"] && + r0["minZ"] == + r1["minZ"] && + r0["maxX"] == + r1["maxX"] && + r0["maxZ"] == + r1["maxZ"]; + } + (*hall)["room"].value( + "persistentId", "") == + "townroom-Test 1-hall-0" && + (*up)["room"].value( + "persistentId", "") == + "townroom-Test 1-up-1"; + if (docOk) { + const auto &rh = (*hall)["room"]; + const auto &ru = (*up)["room"]; + docOk = + !rh.value("createFloor", + true) && + !rh.value( + "createCeiling", + true) && + !rh.value( + "createInteriorWalls", + true) && + !rh.value( + "createWindows", + true) && + rh.value( + "fillRoomWithFurniture", + false) && + rh["tags"].size() == + 1 && + rh["tags"][0] == + "hall" && + ru["tags"][0] == + "up" && + rh["furnitureSeed"] + .get() != + ru["furnitureSeed"] + .get() && + rh["exits"].size() == + 4; + } + } if (docOk) { const nlohmann::json &dc = (*cg)["doorConfigs"][k0];