Phase 8 checkpoint
This commit is contained in:
@@ -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-<stem>.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-<stem>` 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<RigidBodyComponent>` 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_<id>` 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:<gridUid>` 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-<address>-<room>-<floor>`, 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_<driver>_<day>_<serial>`, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GeneratedInteriorService>(m_world,
|
||||
m_sceneMgr);
|
||||
m_interiorNpcSystem =
|
||||
std::make_unique<InteriorNpcSystem>(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<CharacterIdentityComponent>().each(
|
||||
[&](flecs::entity e, CharacterIdentityComponent &ci) {
|
||||
if (e.has<InteriorNpcTag>())
|
||||
return; /* regenerated from the schedule */
|
||||
auto *rec = registry.findCharacter(ci.registryId);
|
||||
if (!rec || !e.has<TransformComponent>())
|
||||
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<RuntimeMarkerComponent>().each(
|
||||
[&](flecs::entity e, const RuntimeMarkerComponent &) {
|
||||
if (e.has<InteriorNpcTag>())
|
||||
return;
|
||||
if (e.has<CharacterIdentityComponent>())
|
||||
return;
|
||||
if (e.has<PlayerControllerComponent>())
|
||||
@@ -2144,6 +2256,8 @@ void EditorApp::saveGame(const std::string &slotPath,
|
||||
};
|
||||
m_world.query<CharacterIdentityComponent>().each(
|
||||
[&](flecs::entity e, CharacterIdentityComponent &ci) {
|
||||
if (e.has<InteriorNpcTag>())
|
||||
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<PrimitiveComponent>();
|
||||
|
||||
/* Phase 8.2 follow-up: player-touched vehicle persistence and
|
||||
* carjack ownership. */
|
||||
m_world.component<VehicleOwnershipComponent>();
|
||||
m_world.component<PlayerTouchedVehicleTag>();
|
||||
m_world.component<InteriorNpcTag>();
|
||||
m_world.component<TriangleBufferComponent>();
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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:<gridUid>")); 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<ActuatorSystem> m_actuatorSystem;
|
||||
std::unique_ptr<DoorSystem> m_doorSystem;
|
||||
std::unique_ptr<GeneratedInteriorService> m_generatedInteriorService;
|
||||
std::unique_ptr<InteriorNpcSystem> m_interiorNpcSystem;
|
||||
std::unique_ptr<StandaloneDoorSystem> m_standaloneDoorSystem;
|
||||
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
|
||||
std::unique_ptr<ItemSystem> 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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-<stem>.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.<id>.json`, legacy `char_<id>.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:<gridUid>`
|
||||
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:<gridUid>` 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_<driver>_<day>_<serial>` 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: <garbage>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-<address>`), 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_<i>_<type>.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<RigidBodyComponent>` 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-<stem>.blend` source files (`init-nude-<stem>` 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_<id>.json` assets to `character.<id>.json`,
|
||||
update demo/staging references deliberately, and exercise the existing
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "LayoutEval.hpp"
|
||||
|
||||
#include "Stairwell.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
@@ -363,16 +365,19 @@ const std::vector<LayoutLibrary::LayoutExample> &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::LayoutExample> &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<Value> &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<Value> &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<Value> &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<Value> &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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
#include "Stairwell.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
|
||||
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<std::string> 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<int64_t> seen;
|
||||
std::vector<std::pair<int, int> > 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<std::pair<int, int> > landings;
|
||||
std::vector<std::pair<int, int> > 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<int, int> &a,
|
||||
const std::pair<int, int> &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<StairwellRoomInstance>
|
||||
planStairwellRooms(const BuildingPlan &plan)
|
||||
{
|
||||
std::vector<StairwellRoomInstance> out;
|
||||
if (!plan.valid() || plan.roomIds.empty())
|
||||
return out;
|
||||
std::set<int> 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<StairwellRoomInstance> acc;
|
||||
for (int y = 0; y < plan.floors; ++y) {
|
||||
std::vector<int> 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<StairwellRoomInstance> 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<StairwellRoomInstance> 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 */
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<StairwellRoomInstance>
|
||||
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 */
|
||||
@@ -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(
|
||||
|
||||
@@ -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<std::string> tags =
|
||||
rb.roomId < (int)plan.roomFurnitureTags.size() ?
|
||||
plan.roomFurnitureTags[rb.roomId] :
|
||||
std::vector<std::string>();
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RigidBodyComponent>() && m_physicsSystem) {
|
||||
auto &rb = parent.get_mut<RigidBodyComponent>();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ public:
|
||||
std::vector<PlacementInfo>
|
||||
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.
|
||||
|
||||
@@ -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<std::string>() == "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<std::string>() == "detailed-nude") {
|
||||
detailed = true;
|
||||
break;
|
||||
}
|
||||
if (detailed)
|
||||
return entry["mesh"].get<Ogre::String>();
|
||||
}
|
||||
if (detailed)
|
||||
return entry["mesh"].get<Ogre::String>();
|
||||
}
|
||||
|
||||
/* Fallback to layer 0 (nude base) — prefer shortest name.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#include "InteriorNpcSystem.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <OgreLogManager.h>
|
||||
|
||||
#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<CellGridComponent>())
|
||||
return 0;
|
||||
const CellGridComponent &grid = interiorGrid.get<CellGridComponent>();
|
||||
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<RoomAnchor> anchors;
|
||||
interiorGrid.children([&](flecs::entity child) {
|
||||
if (!child.has<RoomComponent>())
|
||||
return true;
|
||||
const RoomComponent &room = child.get<RoomComponent>();
|
||||
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<uint64_t> 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<TransformComponent>();
|
||||
t.position = pos;
|
||||
t.rotation = Ogre::Quaternion::IDENTITY;
|
||||
t.applyToNode();
|
||||
|
||||
if (!npc.has<CharacterComponent>()) {
|
||||
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<CharacterComponent>(cc);
|
||||
}
|
||||
if (!npc.has<AnimationTreeComponent>()) {
|
||||
AnimationTreeComponent at;
|
||||
at.treeName = detail ?
|
||||
detail->animationTreeName :
|
||||
Ogre::String("male1");
|
||||
at.useRootMotion = false;
|
||||
npc.set<AnimationTreeComponent>(at);
|
||||
}
|
||||
if (ats)
|
||||
ats->setState(npc, "locomotion", "idle");
|
||||
npc.add<InteriorNpcTag>();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include <flecs.h>
|
||||
|
||||
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-<address>"). 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<flecs::entity_t> &spawned() const
|
||||
{
|
||||
return m_spawned;
|
||||
}
|
||||
|
||||
private:
|
||||
flecs::world &m_world;
|
||||
EditorApp *m_app;
|
||||
std::vector<flecs::entity_t> m_spawned;
|
||||
size_t m_eligible = 0;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<int> 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<uint64_t> ids = pop->populationCharacterIds();
|
||||
std::set<int> 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<AiDriverComponent>();
|
||||
/* 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<EntityNameComponent>()) {
|
||||
car.vehicle
|
||||
.get_mut<EntityNameComponent>()
|
||||
.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<PlayerTouchedVehicleTag>();
|
||||
VehicleOwnershipComponent owner;
|
||||
owner.ownerId = car.driverId;
|
||||
car.vehicle.set<VehicleOwnershipComponent>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <OgreVector3.h>
|
||||
#include <OgreQuaternion.h>
|
||||
#include <flecs.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#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<uint64_t, TrafficCar> m_cars; /* key: driver registry id */
|
||||
/** Carjacked cars by driver: owner walks today, new car next day. */
|
||||
std::map<uint64_t, StolenCar> m_stolenCars;
|
||||
uint64_t m_stolenSerial = 0;
|
||||
std::set<int> m_seededAreas;
|
||||
/** Slot claims (6.6.3): claimKey -> driver registry id. A
|
||||
* per-frame pure snapshot rebuilt by rebuildClaims() — never
|
||||
|
||||
@@ -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<JoltPhysicsWrapper>(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<RigidBodyComponent>()
|
||||
.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;
|
||||
|
||||
@@ -91,6 +91,16 @@ private:
|
||||
// Query for entities with RigidBody
|
||||
flecs::query<RigidBodyComponent, TransformComponent> 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;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "TownGenSystem.hpp"
|
||||
#include <OgreTerrainGroup.h>
|
||||
#include <OgreMaterialManager.h>
|
||||
#include <OgreRTShaderSystem.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<RoomComponent>())
|
||||
return true;
|
||||
const auto &r =
|
||||
ch.get<RoomComponent>();
|
||||
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<CellGridComponent>()
|
||||
.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<CellGridComponent>();
|
||||
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<TransformComponent>()) {
|
||||
Ogre::MeshPtr ext =
|
||||
stairTestAttachedMesh(
|
||||
tinyBuilding
|
||||
.get<TransformComponent>()
|
||||
.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<RoomComponent>())
|
||||
return true;
|
||||
++roomCount;
|
||||
const auto &r = child.get<RoomComponent>();
|
||||
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<TransformComponent>()) {
|
||||
Ogre::SceneNode *node = grid.get<TransformComponent>().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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -282,6 +282,9 @@ bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle,
|
||||
vehicle.add<PlayerControlledComponent>();
|
||||
|
||||
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<PlayerTouchedVehicleTag>();
|
||||
m_vehicleId = vehicle.id();
|
||||
m_characterId = character.id();
|
||||
m_controllerId = controller.id();
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#include "VehicleStateStore.hpp"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include <OgreLogManager.h>
|
||||
|
||||
#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<PlayerTouchedVehicleTag>() &&
|
||||
!e.has<PlayerControlledComponent>())
|
||||
return;
|
||||
if (!e.has<EntityNameComponent>())
|
||||
return;
|
||||
const std::string name =
|
||||
e.get<EntityNameComponent>().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<VehicleOwnershipComponent>())
|
||||
r.ownerId =
|
||||
e.get<VehicleOwnershipComponent>().ownerId;
|
||||
r.runtime = e.has<RuntimeMarkerComponent>();
|
||||
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<VehicleComponent>())
|
||||
found = e;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
void placeVehicle(flecs::entity entity, const VehicleStateStore::Record &r,
|
||||
RenderOriginSystem *origin, JoltPhysicsWrapper *physics)
|
||||
{
|
||||
if (!entity.has<TransformComponent>())
|
||||
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<TransformComponent>();
|
||||
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<RigidBodyComponent>() && physics) {
|
||||
auto &rb = entity.get_mut<RigidBodyComponent>();
|
||||
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<VehicleComponent>()) {
|
||||
auto &vc = entity.get_mut<VehicleComponent>();
|
||||
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<RuntimeMarkerComponent>();
|
||||
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<PrefabInstanceComponent>())
|
||||
entity.get_mut<PrefabInstanceComponent>()
|
||||
.instantiated = true;
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"VehicleStateStore: restored runtime vehicle '" +
|
||||
r.name + "'");
|
||||
}
|
||||
if (!entity.is_alive() || !entity.has<VehicleComponent>())
|
||||
continue;
|
||||
placeVehicle(entity, r, origin, physics);
|
||||
entity.add<PlayerTouchedVehicleTag>();
|
||||
if (r.ownerId != 0) {
|
||||
VehicleOwnershipComponent owner;
|
||||
owner.ownerId = r.ownerId;
|
||||
entity.set<VehicleOwnershipComponent>(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<float>(),
|
||||
rj["rot"][1].get<float>(),
|
||||
rj["rot"][2].get<float>(),
|
||||
rj["rot"][3].get<float>());
|
||||
r.ownerId = rj.value("owner", 0ULL);
|
||||
r.runtime = rj.value("runtime", false);
|
||||
if (rj.contains("entity"))
|
||||
r.entity = rj["entity"];
|
||||
upsert(std::move(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <OgreQuaternion.h>
|
||||
#include <OgreVector3.h>
|
||||
#include <flecs.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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<Record> &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<Record> m_records;
|
||||
|
||||
void upsert(Record record);
|
||||
};
|
||||
@@ -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<std::string> 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<uint32_t>() !=
|
||||
ru["furnitureSeed"]
|
||||
.get<uint32_t>() &&
|
||||
rh["exits"].size() ==
|
||||
4;
|
||||
}
|
||||
}
|
||||
if (docOk) {
|
||||
const nlohmann::json &dc =
|
||||
(*cg)["doorConfigs"][k0];
|
||||
|
||||
Reference in New Issue
Block a user