taxi: Phase 0

This commit is contained in:
2026-09-15 15:05:41 +03:00
parent 6d10fec2a0
commit 55cd5f3c52
14 changed files with 4206 additions and 0 deletions
+16
View File
@@ -251,6 +251,22 @@ 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
# 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,
# drivable sedans, passenger/foot-traffic/vehicle-traffic NPCs, chained
# timed fares with doubling payout) is added step by step - see
# demos/demo-taxi/PLAN.md. Vehicle ideas come from demo-sokoban.
cd demos/demo-taxi
./demoTaxi
# ...or headless smoke run (one frame, then exit):
./demoTaxi --headless --exit-after-first-frame
# ...or headless end-to-end check of the inherited scene contract:
./demoTaxi --headless --test-switch
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
# Road wedge/segment self-intersection regression test (no scene needed,
# also registered as CTest roadGeometryOverlapTest)
+1
View File
@@ -1064,3 +1064,4 @@ add_subdirectory(demos/demo-scene-switching)
add_subdirectory(demos/demo-scene-switching-extra)
add_subdirectory(demos/demo-interior-exterior-dynamics)
add_subdirectory(demos/demo-sokoban)
add_subdirectory(demos/demo-taxi)
@@ -0,0 +1,127 @@
# ---------------------------------------------------------------------------
# demo-taxi — taxi driver demo: procedurally generated town, drivable sedans,
# passenger/foot-traffic/vehicle-traffic NPCs, chained taxi fares
# ---------------------------------------------------------------------------
# Starts as an exact copy of demos/demo-interior-exterior-dynamics (same
# scene-switch-door setup, same streaming-terrain/sky/water exterior at
# world center ~(4000, 4000)); the taxi game (town generation, sedans,
# NPC traffic, fare chain) is built step by step on top of it - see
# PLAN.md in this directory. Vehicle ideas come from demos/demo-sokoban
# (Jolt VehicleConstraint, VehicleControllerSystem enter/exit flow).
#
# The executable is self-contained in this build directory: a POST_BUILD
# step (stage_runtime.cmake) copies resources.cfg, the
# character prefab (prefabs/char_2.json, written by a previous editor/game
# run) and any runtime config JSONs here, and symlinks both demo scenes
# (demo_scene_interior.json / demo_scene_exterior.json, so scene edits in
# the source tree are visible without a rebuild) plus the big pre-staged
# runtime directories (resources/, characters/, lua-scripts/) from the
# editScene binary directory; the terrain heightmap is staged separately
# by configure_file (see below) so source changes re-copy it on the next
# build. Run it from here:
# cd <build>/src/features/editScene/demos/demo-taxi
# ./demoTaxi
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
# --- Embedded project configuration (F8 release binary) ---------------
# Read project.json at configure time and embed its parameters into the
# binary via a generated project.h, so the release binary is attached to
# its project directory without needing --project. Editing project.json
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${PROJECT_JSON_PATH}")
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
if(PROJECT_GAME_MODE)
set(PROJECT_GAME_MODE_VALUE 1)
else()
set(PROJECT_GAME_MODE_VALUE 0)
endif()
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
# Terrain heightmap for the exterior scene's terrain entity (terrainId
# 4242424300000001, heightmapFile heightmap.bin — TerrainSystem resolves it
# as heightmaps/<terrainId>/<heightmapFile> relative to the CWD). Generated
# in the source tree by gen_heightmap.py. The terrain runs in streaming
# mode (base heights come from baseNoise, not the heightmap), but the file
# stays staged so flipping streamingEnabled off keeps working. A
# configure_file COPYONLY copy is used on purpose: it registers a configure
# dependency on the source file, so regenerating heightmap.bin re-copies it
# into the staging directory on the next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
add_executable(demoTaxi
demo_main.cpp
${DEMO_SOURCES}
)
target_compile_definitions(demoTaxi
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
add_dependencies(demoTaxi morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoTaxi PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoTaxi
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoTaxi PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoTaxi POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-taxi standalone runtime"
)
@@ -0,0 +1,432 @@
# demo-taxi — Implementation Plan
**Status: Phase 0 done (2026-09-15).** Demo skeleton only; all game
phases pending. This plan is a working draft — phases get refined,
split or reordered as we work through them.
Taxi driver demo in the open world of `demo_scene_exterior.json`: a
procedurally generated (but tightly controlled) settlement on the
streaming terrain, two drivable sedan-type cars for the player, taxi
passenger NPCs, foot-traffic NPCs on sidewalks, vehicle traffic with
NPC drivers, and a fare chain — the player must complete 10 taxi fares,
each fare paying double the previous one, delivering each passenger
within a time limit derived from the trip distance. Missing the timer
fails the whole mission and resets the chain.
This demo starts as an exact copy of `demo-interior-exterior-dynamics`
(scene-switch doors interior <-> exterior, streaming terrain
archipelago, sky, water). Vehicle ideas come from `demo-sokoban`
(Jolt `VehicleConstraint`, `VehicleControllerSystem` enter/exit flow,
`--test-vehicle*` headless test patterns).
## Goals / non-goals
Goals:
- **Easy procedural creation of settlements.** Today building a town
by hand (roads node-by-node, lots/buildings one by one) takes
endless amounts of time. The headline improvement of this demo is
generator tooling: a road-network generator and a town generator
with explicit, controllable parameters and deterministic seeds, so a
village/town/city is a parameter choice, not a week of clicking.
- **Multiple streamed settlements.** One settlement occupies a square
of at most 1000 x 1000 m; the world can hold several of them.
Everything streams (terrain pages, road/spawner region stores,
navmesh tiles, NPC and traffic spawn radii) so FPS stays good.
- **Enterable buildings.** Every generated building can be entered
through an F1 scene-switch door; the matching interior is generated
on the fly (deterministic from the building's exterior parameters).
Until that phase lands, exterior doors stay disabled.
- **NPC and vehicle control infrastructure.** AI-driven vehicles on
the road graph, pedestrians on sidewalks, passengers entering and
leaving the player's taxi — all reusable engine systems, not demo
hacks.
- **Realistic foot traffic.** People leave their houses, walk along
the sidewalks and join other pedestrians — or walk to their own
vehicle, get in and join the vehicle traffic.
- **The taxi game loop** on top: pickup -> timed drive -> dropoff ->
next fare, doubling payout, 10 fares to win.
Non-goals (for now):
- No multiplayer, no traffic-code simulation beyond what gameplay needs
(speed limits, intersection priority, basic collision avoidance).
- No full economy/quest system — the fare chain is a self-contained
activity like the sokoban yard (Lua rules + GlobalStateStore + HUD).
## Design decisions (agreed with the user)
- **Engine-level, not demo-level** (same rule as demo-sokoban): all new
components/systems/Lua APIs live in `src/features/editScene`
(`components/`, `systems/`, `lua/`, `ui/`, road generators next to
`roadlib/`) so any demo or scene can use them. `demo-taxi` itself
contributes scene JSON data, assets, demo-owned config and headless
test listeners in `demo_main.cpp` — no game logic in the demo binary.
- **Lua runs the game rules** (same pattern as `sokoban.lua`): C++
provides per-frame physics-adjacent detection and emits `EventBus`
events; fare rules (price doubling, timers, completion) live in a
shared Lua module (e.g. `lua-scripts/taxi.lua`). No per-frame Lua
polling beyond the existing throttled `frame` event pattern.
- **Deterministic generation**: every procedural step (road network,
town layout, interiors, NPC placement) takes an explicit seed; same
seed + same parameters = same town. Generated output is *data in
the existing formats* (RoadGraph nodes/edges in road region files,
scene entities, spawner defs), so it remains hand-editable
afterwards — generation is a starting point, not a black box.
- **Streaming-compatible**: the exterior terrain keeps
`streamingEnabled`; generated content must land in the region stores
(`heightmaps/<terrainId>/roads/`, `.../spawners/`) when streaming,
inline otherwise. A settlement fits in a 1000 x 1000 m square so a
settlement's content covers at most one terrain page (2000-unit
pages) plus margin; multiple settlements sit in different regions of
the same world.
- **Vehicles**: several sedan types, each in multiple colors, for both
the player cars and traffic. Many more vehicle types come later
(Phase 11); the vehicle pipeline must not hardcode "sedan".
- **Fare failure**: missing the delivery timer fails the whole
mission — the chain resets to fare 1 and the payout multiplier is
lost.
- **Feature numbering**: `GameFeatures202609.md` has F0–F12 used; new
engine features from this plan get F13+ entries there as they land,
and AGENTS.md files are updated per the project rules.
## Existing pieces we build on
- `VehicleComponent` / `VehicleSystem` (F10) — Jolt `VehicleConstraint`
+ `WheeledVehicleController`, per-wheel suspension/steering params,
wheel/steering-wheel visuals, camera boom fields. The sedan is a
configuration of this (front steer, no fork, higher speed).
- `VehicleControllerSystem` (F10) — player enter/exit (`vehicle_enter`
event, seat pinning, driving camera, controls hint). Passenger
seating will extend this.
- `RoadSystem` + `roadlib/RoadGeometryLib` — per-terrain `RoadGraph`,
wedge/segment geometry with colliders, sidewalks, roadside prefab
slots, terrain compliance both directions, M5 region streaming.
Intersections already work at the visual level (wedge fans join
edges at multi-neighbor nodes). `ProceduralRoadGeometry.md`
documents the geometry; `ProceduralRoadGeometryImprovement.md`
tracks known improvements.
- `TownComponent` / `DistrictComponent` / `LotComponent` +
`CellGridSystem` — procedural building generation with a
Town -> District -> Lot material/template hierarchy (currently
circle-arranged districts; the generators below replace the tedious
manual arrangement, not the building generator). CellGrid supports
multi-storey buildings (`height` = floors) but generates no stairs,
so upper floors are unreachable.
- Scene switching (F1 scene-switch doors, `EditorApp::switchScene`,
per-scene arrival targets) — the mechanism enterable buildings build
on; `demo_scene_interior.json`/`demo_scene_exterior.json` in this
demo are the working example.
- `NavMeshSystem` (tile cache) + `PathFollowingSystem` — pedestrian
locomotion on navmesh, door-aware; Recast climbs geometry within the
configured step height, so stair geometry is navmesh-compatible.
- `CharacterSpawnerSystem` + `CharacterRegistry` + animation trees —
distance-based NPC spawn/despawn; streamed spawner region store (M4).
- GOAP / BehaviorTree / SmartObject / Actuator systems — NPC behaviour
glue and player interaction prompts.
- `ZoneSystem` / `TargetZoneComponent` (F11) — generic zone detection,
reusable for pickup/dropoff curbs.
- `GameHudSystem` + `ecs.hud.*` (F12) — status lines and banners.
- `GlobalStateStore` (F9) — persistent fare-chain state
(`taxi.fare.index`, `taxi.money`, ...).
- `EventBus`, `SceneScriptComponent`, `lua-examples/` — event/Lua glue.
- Demo scaffolding in this directory (CMake target, embedded
`project.json`, symlinked scenes, `--headless` smoke and
`--test-switch` listener patterns in `demo_main.cpp`).
- demo-sokoban reference material: sedan-relevant tuning gotchas in
`AGENTS.md` ("VehicleSystem (F10)"), its `animation_tree.json`
"driving" state, `--test-vehicle-drive` headless test flow,
`assets/blender/scripts/gen_sokoban_assets.py` as the asset-generator
script pattern.
## Pieces that do not exist yet (to build)
1. Sedan vehicles: no car meshes, no car-tuned `VehicleComponent`
preset. Reference models exist
(`assets/blender/vehicles/Car5_Taxi.blend`,
`assets/blender/vehicles/Car2.blend`) but cannot be used directly —
they lack separate doors, wheels, trunk and engine cover.
2. Road geometry improvements: intersection wedges are not smoothed at
the edge and often overlap because too little space is reserved for
lanes and sidewalks; the procedural lane template emits front/back
geometry even where the intersection should be open; terrain
compliance is manual and tedious.
3. Road-network generation: roads are authored node-by-node with the
Connect tool today; no layout generator.
4. Town generation: District/Lot exist but arrange lots in a circle
and require manual setup; no road-following lot placement, no
village/town/city presets. We need road-specific lot components
that place buildings along road frontages — from CellGrid templates
or plain prefabs — with easy-to-set parameters.
5. Stair generation: CellGrid multi-storey buildings (`height` > 1)
have stacked floors with no connection between them — no stair
cells, no floor openings, nothing for the character or the navmesh
to climb.
6. On-the-fly interiors: no way to generate an interior scene matching
a generated building's exterior at scene-switch time.
7. AI vehicle driving: vehicles are player-only; no road-following AI
driver, no speed/intersection logic, no NPC seated in a vehicle.
Intersections have no traffic controls (priority rules) at all.
8. Foot traffic: no pedestrian wandering/scheduling on sidewalks, no
house -> sidewalk -> own-car flows.
9. Passenger mechanics: no NPC vehicle boarding/alighting, no
passenger seat, no hail/pickup flow.
10. Fare chain: no fare/timer/money rules (Lua module + HUD).
11. Assets: sedans (several types x several colors, one taxi-marked),
possibly street props (lamps, signs).
## Phases
Each phase ends in a buildable, runnable demo with headless
verification; verification commands assume the build tree (e.g.
`build-vscode`) is configured. Phase scopes below are initial
estimates — expect splits as we learn.
### Phase 0 — Demo skeleton — DONE (this step)
Copy of `demo-interior-exterior-dynamics` with target `demoTaxi`,
`project.json` appName `demo-taxi`, registered via
`add_subdirectory(demos/demo-taxi)` in
`src/features/editScene/CMakeLists.txt`.
Verify:
```bash
cmake --build build-vscode --target demoTaxi -j4
cd build-vscode/src/features/editScene/demos/demo-taxi
./demoTaxi --headless --exit-after-first-frame
./demoTaxi --headless --test-switch # inherited scene contract
```
### Phase 1 — Sedan vehicles
- New sedan meshes through the Blender asset pipeline (pattern:
`gen_sokoban_assets.py`), using the reference models
`assets/blender/vehicles/Car5_Taxi.blend` (taxi) and
`assets/blender/vehicles/Car2.blend` (plain sedan) as shape
references. They cannot be used directly — they lack separate
doors, wheels, trunk and engine cover — so the export splits those
parts. Scale to real car dimensions: length ~5 m, width ~1.9 m,
height ~1.2 m (wheels modelled at the origin like the forklift's).
- Several sedan types (silhouette variants) x several colors/materials
so traffic is distinguishable; one type is taxi-marked (sign/roof
marking). Keep the pipeline generic — many more vehicle types come
in Phase 11.
- Car-tuned `VehicleComponent`: front-wheel steering (no `rearSteer`),
no fork, higher `maxTorque`/gearing than the forklift, sedan
`cameraDistance`/`cameraHeight`, `seatOffset`.
- Demo-owned `animation_tree.json` (copy demo-sokoban's — it carries
the "driving" locomotion state).
- Two player sedans parked in the exterior scene near the content
site, enterable via the existing `vehicle_drive` actuator action.
Only one of them is the taxi and starts the taxi loop; the other is
for plain driving.
- Headless: `--test-vehicle-drive`-equivalent for the sedan (spawn,
enter, drive, steer, brake, exit) on a flat test scene.
### Phase 2 — Road geometry & compliance improvements
Geometry-level fixes that both hand editing and the Phase 3 generator
need (intersections already work visually; this makes them correct and
terrain-friendly):
- Intersection inner geometry: the procedural lane template must not
emit front/back geometry at multi-neighbor nodes (open junction
area); front/back caps only for single-neighbor (dead-end) nodes.
- Intersection overlap: smooth the wedge edges at intersections and
reserve enough space for lanes + sidewalks so adjacent wedges stop
overlapping.
- Compliance workflow: make terrain compliance automatic instead of
tedious — a single action (and an automatic pass after generation)
that chains roads-to-terrain and terrain-to-roads until stable.
- Headless: extend/reuse the `road_geometry_overlap_test` machinery —
no self-intersections at 3-/4-way nodes, dead-end caps present,
compliance pass converges and is idempotent.
### Phase 3 — Road-network generator
The tedious part today: every node/edge is hand-placed. Build a
generator that emits a `RoadGraph` (region files when streaming):
- Grid layout first (block size, road width, jitter), explicit seed,
fully deterministic. (Organic/radial layouts are deferred to
Phase 11.) A network is bounded to the settlement square
(max 1000 x 1000 m); generating another settlement is just another
generator invocation at a different origin.
- Terrain-aware placement: sample `getHeightAt`, reject or relocate
segments over water or too-steep slopes, then run the Phase 2
automatic compliance pass.
- Editor entry point: a "Generate Road Network" panel on the terrain
(parameters, seed, generate/regenerate, clear).
- Headless: generate a network, assert node/edge counts, intersection
valences, no self-intersections, region files written and reload
identically; two settlements in one world do not interfere.
### Phase 4 — Procedural town generation
On top of the generated road network:
- Road-side lot components (new): lots derived from road-edge
frontages (frontage/depth parameters), each placing a building from
a CellGrid template or a plain prefab, with easy-to-set parameters.
- District zoning presets (village / town / city-residential /
city-center) driving building templates, heights, densities.
- Building placement: orient to the street, snap to terrain, run
terrain compliance per lot. Every building gets its (disabled for
now) F1 scene-switch exterior door config, so Phase 8 only has to
enable and wire them.
- Street props: sidewalks everywhere (RoadConfig), roadside prefab
slots for lamps/signs/trees where appropriate.
- Spawnable points of interest: named curbs/stops that later phases
use as passenger pickup/dropoff locations (and as pedestrian
origins/destinations).
- Streaming budget: all settlement content goes through the region
stores (roads, spawners) or distance-based spawn/despawn so an
unloaded settlement costs nothing; two settlements in one world
stream independently.
- Deterministic seed; generated entities stay editable scene/spawner
data (region stores when streaming).
- Headless: generate the demo town, assert building count per zone,
all buildings on dry land above water level, every building carries
its (disabled) scene-switch door config, navmesh rebuilds.
### Phase 5 — NPC vehicle traffic
- AI driver system (new, engine-level): a vehicle follows the road
graph (route = path over edges), respects per-edge speed limits,
basic collision avoidance (raycast/whisker braking for the vehicle
ahead and for the player).
- Intersection traffic controls: priority rules on intersection nodes
(the visual intersections from Phase 2 get behaviour), AI drivers
slow/stop/yield accordingly.
- NPC driver characters seated in the vehicles (visual occupancy;
reuse the seat-pinning approach of `VehicleControllerSystem`).
- Traffic vehicles drawn from the Phase 1 sedan pool (type + color
variants).
- Traffic density control: spawn/despawn traffic vehicles by distance
to the camera (spawner pattern), cap active count.
- Headless: two AI cars on a generated loop complete N laps without
collision/assert; intersection yield check; despawn/respawn
stability.
### Phase 6 — Foot traffic NPCs
- Pedestrian system: spawners at houses/POIs, pedestrians walk navmesh
routes along sidewalks between points of interest, avoid standing on
the road, despawn by distance.
- Realistic flows: a pedestrian leaves a house (door/curb POI), joins
the sidewalk traffic, and either walks to a destination or enters
their own parked vehicle and drives off as traffic (hand-off to the
Phase 5 AI driver); the reverse flow parks and walks home. (Real
walk-through-interior exits arrive with Phase 8; until then "leaving
a house" starts at its door POI.)
- Uses existing `NavMeshSystem` + `PathFollowingSystem` +
`CharacterSpawnerSystem`; the new work is orchestration data
(sidewalk waypoint extraction from the road graph) plus the flow
scheduler.
- Headless: pedestrians spawn, reach waypoints, never path through
building interiors; house -> car hand-off completes; counts stay
within caps while the camera moves.
### Phase 7 — CellGrid stair generation
Multi-storey CellGrid buildings (`height` > 1) generate stacked floors
with no connection between them — interiors above ground level are
unreachable. Add stairs to the CellGrid generator (engine-level, not
taxi-specific):
- Stair cell: a cell flag (like the existing door/window flags) marking
a stairwell; the generator cuts the matching opening in the
floor/ceiling above and emits a stair mesh (steps sized within the
Jolt character's and Recast's climb limits) plus walkable colliders.
- Placement rules: stairwell position comes from the room layout
(RoomLayoutSystem integration) or an explicit cell flag in
hand-authored grids; a stairwell column stacks through all floors.
- Navmesh: stairs rasterize as walkable (Recast step/slope limits), so
NPCs can path between floors; verify doorway-area costs and door
obstacles still behave.
- Editor + serialization + Lua binding, per the component rules
(serialized flag, Cell Grid editor toggle, `cellgrid_door_test`-style
headless coverage).
- Headless: multi-floor grid with a stairwell — character walks from
ground floor to the top floor (physics), navmesh query finds a
ground->top path; grids without stair flags are unchanged.
### Phase 8 — Enterable buildings (on-the-fly interiors)
- Interior scene generator (new, engine-level): given a building's
exterior parameters (footprint, floors, template, seed), generate
the matching interior scene deterministically — same building, same
interior, every visit. Generated as a scene at switch time (or
cached on first visit), not pre-authored per building. Multi-storey
buildings get Phase 7 stairwells so every floor is traversable.
- Enable the F1 scene-switch exterior doors prepared in Phase 4
(doorConfigs `disabled` -> wired with `sceneSwitchPath`/target);
entering switches to the generated interior, the interior's exit
door switches back to the player's exterior position.
- Interior content level for v1: walls/floor/ceiling/stairs/windows
matching the exterior shell, no furniture gameplay.
- Headless: for N generated buildings, enter -> interior loads and
matches exterior footprint -> exit returns to the curb; two visits
produce identical interiors (determinism check); a multi-storey
building's upper floor is reachable by stairs.
### Phase 9 — Taxi passengers
- Passenger NPCs waiting at curbs/POIs; hail interaction (E prompt via
ActuatorSystem or proximity hail while driving slowly).
- NPC boarding/alighting: walk to the car door, play sit state, ride
as passenger (new passenger-seat support — vehicle carries extra
seated NPCs; extends `VehicleComponent` with a seat list), walk away
at dropoff.
- Destination assignment: passenger names a POI/curb from the
generated town (any settlement in the world); route distance
computed over the road graph (drives the fare timer).
- Headless: scripted passenger boards, rides, alights at destination;
vehicle carries passenger through a scene-physics run.
### Phase 10 — Fare chain gameplay (the taxi game)
- `lua-scripts/taxi.lua` (pattern of `sokoban.lua`): fare state
machine (awaiting fare -> passenger aboard -> delivered/failed),
price doubling per completed fare (fare 1 = base price, fare 10 =
base * 512), timer from road-graph route distance (e.g. distance /
expected speed * slack factor), money counter.
- Failure rule: missing the timer fails the whole mission — chain
resets to fare 1, multiplier lost, HUD banner explains.
- `GameHudSystem` display: current fare index n/10, fare price, time
remaining, total money; banners on pickup/delivery/fail; win banner
after fare 10.
- Persistence in `GlobalStateStore` under `taxi.*` so the chain
survives scene switches and save/load.
- Headless: scripted 3-fare run asserting prices double, timer scales
with distance, a timeout resets the chain to fare 1, completion
events fire, HUD state updates.
### Phase 11 — Generator extras & vehicle variety (as needed)
Less critical items deferred from earlier phases; pull in when the
demo needs them:
- Organic layout (looped/curved arterial + side streets) and radial
village layout for the road generator.
- Bulk-edit ops for road graphs (delete region, translate/rotate
selection) — generation makes these necessary sooner or later.
- Asymmetric roads (different lane/sidewalk setup per side) and road
narrowing/widening along an edge.
- Generation preview in the editor (ghost overlay before committing).
- More vehicle types beyond sedans (the Phase 1 pipeline is already
generic): vans, trucks, buses; service vehicles.
### Phase 12 — Demo assembly, polish, docs
- Assemble the full demo scene: at least one full settlement on the
exterior terrain (optionally a second one to prove
multi-settlement streaming), two player sedans, traffic +
pedestrians + passengers live, interior scene kept as the
"home"/menu-adjacent space.
- Balance pass: speeds, timers, fare slack, traffic density.
- Performance pass: streaming radii, navmesh tile budget, active NPC
caps — measure FPS with both settlements placed.
- Docs: update `AGENTS.md` (both levels), `GameFeatures202609.md`
(F13+ entries), demo README section; full headless test battery
wired into the demo like demo-sokoban's.
@@ -0,0 +1,983 @@
#include <iostream>
#include <cstdlib>
#include "EditorApp.hpp"
#include "ProjectConfig.hpp"
#include "camera/EditorCamera.hpp"
#include "systems/CharacterRegistry.hpp"
#include "systems/DoorSystem.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/EventBus.hpp"
#include "systems/GlobalStateStore.hpp"
#include "components/Door.hpp"
#include "components/EntityName.hpp"
#include "components/Transform.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
#include <filesystem>
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
#include "project.h"
#endif
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/* Debug capture aid (for offscreen/xvfb screenshots):
* --force-pos x y z yawDeg [pitchDeg]
* teleport the player character once, as
* soon as it is alive (physics picks the
* new position up through the usual
* node->physics sync, gravity/buoyancy
* then act naturally); yaw/pitch (degrees)
* are re-applied to the TPS camera every
* frame via PlayerControllerSystem;
* --screenshot N path write frame N of the render window to
* path (RenderWindow::writeContentsToFile)
* and quit.
* --debug-buoyancy enable BuoyancySystem debug logging (same
* as the editor's --debug-buoyancy). */
struct DebugCaptureListener : public Ogre::FrameListener {
EditorApp *app;
bool teleportEnabled = false;
bool teleported = false;
Ogre::Vector3 teleportPos = Ogre::Vector3::ZERO;
float teleportYawDeg = 0.0f;
float teleportPitchDeg = 0.0f;
int shotFrame = -1;
Ogre::String shotFile;
int frame = 0;
DebugCaptureListener(EditorApp *a) : app(a) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (teleportEnabled) {
flecs::entity player = app->getPlayerCharacterEntity();
if (player.is_alive() &&
player.has<TransformComponent>()) {
TransformComponent &t =
player.get_mut<TransformComponent>();
if (t.node) {
/* Re-teleport while the character is below
* the terrain surface at the target: the
* streaming terrain colliders may not be
* built yet on the first frames, so the
* character can fall through. Once the
* collider exists it stands (or floats in
* deep water) at/above the surface and the
* teleport stops. */
bool fellThrough = false;
TerrainSystem *ts =
TerrainSystem::getInstance();
if (teleported && ts && ts->isActive()) {
float ground = ts->getHeightAt(
teleportPos);
fellThrough =
t.node->getPosition().y <
ground - 0.5f;
}
if (!teleported || fellThrough) {
t.node->setPosition(teleportPos);
t.node->setOrientation(Ogre::Quaternion(
Ogre::Degree(teleportYawDeg),
Ogre::Vector3::UNIT_Y));
teleported = true;
}
}
}
}
/* The TPS camera yaw lives in PlayerControllerSystem state,
* not in the character node orientation, so re-apply the
* requested yaw/pitch every frame once teleported. */
if (teleported && app->getPlayerControllerSystem())
app->getPlayerControllerSystem()->setControllerYawPitch(
teleportYawDeg, teleportPitchDeg);
if (shotFrame > 0 && frame >= shotFrame) {
app->getRenderWindow()->writeContentsToFile(shotFile);
app->getRoot()->queueEndRendering();
}
return true;
}
};
/* Same application as the editor/game, but with a larger initial window. */
class DemoApp : public EditorApp {
public:
using EditorApp::EditorApp;
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override
{
(void)w;
(void)h;
return EditorApp::createWindow(name, 1920, 1080, miscParams);
}
};
/*
* The demo floor mesh and its flat colored material are created
* programmatically because RenderableComponent only references a mesh by
* name and carries no material/color of its own. Only the interior scene
* needs one (the green "DemoFloorPlaneInterior"); the exterior scene
* walks on the terrain instead.
*/
static void createFloorMesh(const Ogre::String &meshName,
const Ogre::String &materialName,
float dr, float dg, float db)
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName(meshName, group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
materialName, group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(dr, dg, db, 1.0f);
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
mesh->getSubMesh(0)->setMaterialName(materialName);
}
static void createDemoResources()
{
/* Interior scene: green floor. The exterior scene has no floor
* mesh — the terrain provides the ground. */
createFloorMesh("DemoFloorPlaneInterior", "DemoFloorMaterialInterior",
0.35f, 0.5f, 0.35f);
}
/*
* End-to-end check for --test-switch: drives the same path an E-press on
* a scene-switch door takes (DoorComponent toggleRequested +
* sceneSwitchPending -> DoorSystem swing -> EditorApp::switchScene()
* queue once the leaf is fully open -> performSceneSwitch() on the next
* frame with the "@arrival_*" teleport), for a full
* interior -> exterior -> interior round trip: the interior scene exits
* through the "interrior" grid's door Z:0:0:15 and the exterior scene
* returns through its own grid's door Z:0:0:0. The prompt/targeting
* glue is screen-space and therefore not covered headless.
*/
struct SceneSwitchTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 10; /* 10-12: F6 locked door, then 0-3 scene switching */
bool failed = false;
Ogre::String failReason;
/* F6 demo door: internal doorway Z:0:0:8 of the "interrior" grid,
* lockable + locked by default (doorConfigs in
* demo_scene_interior.json); the inline scene script unlocks it on
* the first door_locked bump. */
const Ogre::String doorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8";
/* F1 demo doors: the interior scene exits through the external
* doorway Z:0:0:15 of the "interrior" grid (scene-switch door to
* demo_scene_exterior.json), the exterior scene returns through the
* external doorway Z:0:0:0 of its own exteriorOnly grid
* (scene-switch door back to demo_scene_interior.json). For both
* legs the switch must fire only when the leaf is fully open. */
const Ogre::String exitDoorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:15";
const Ogre::String returnDoorId =
"77370b1d-e8ab-44a6-865b-e55c15b0fc78:Z:0:0:0";
int exitOpenWait = 0;
int returnOpenWait = 0;
int arrivalDeadline = 0;
/* arrival_b in the exterior scene: 3 units outside (-Z of) the
* exteriorOnly grid's return door Z:0:0:0 near the streaming-world
* center. The expected Y is the terrain surface height there, read
* back from the engine (streaming terrain: base heights come from
* baseNoise, so no flat Y can be hardcoded). */
Ogre::Vector3 expectedArrivalB() const
{
Ogre::Vector3 expected(4000.6f, 0.0f, 3995.9f);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->isActive())
expected.y = ts->getHeightAt(ts->worldToRender(
expected.x, 0.0, expected.z));
return expected;
}
SceneSwitchTestListener(EditorApp *a)
: app(a)
{
}
bool entityExists(const char *name)
{
bool found = false;
app->getWorld()->query<EntityNameComponent>().each(
[&](flecs::entity, EntityNameComponent &n) {
if (n.name == name)
found = true;
});
return found;
}
/* The F6 demo door entity (recreated on every scene load). */
flecs::entity findDoor()
{
return findDoorById(doorId);
}
flecs::entity findDoorById(const Ogre::String &id)
{
flecs::entity result = flecs::entity::null();
app->getWorld()->query<DoorComponent>().each(
[&](flecs::entity e, DoorComponent &d) {
if (d.doorId == id)
result = e;
});
return result;
}
/* Emulate the E-press on a scene-switch door. */
bool requestDoorSwitch(const Ogre::String &id)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
DoorComponent &d = door.get_mut<DoorComponent>();
if (!d.occluder) {
failed = true;
failReason = "F1 scene-switch door has no occluder";
return true;
}
d.toggleRequested = true;
d.sceneSwitchPending = true;
return true;
}
/* Waits for the queued scene switch of a scene-switch door;
* returns true once the switch to expectPath is pending (the door
* must be fully open by then). */
bool waitDoorSwitch(const Ogre::String &id, const Ogre::String &label,
const char *expectPath, int &openWait)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
const DoorComponent &d = door.get<DoorComponent>();
bool fullyOpen = d.isOpen && d.currentAngle == d.openAngle;
if (app->hasPendingSceneSwitch()) {
if (!fullyOpen) {
failed = true;
failReason =
"F1 scene switch fired before the door was fully open";
return true;
}
if (app->getPendingSceneSwitchPath() != expectPath) {
failed = true;
failReason = "F1 wrong pending switch path";
return true;
}
std::cout << "[test] F1 " << label
<< " fully open, switch queued" << std::endl;
return true;
}
if (fullyOpen) {
/* Frame-listener ordering grace. */
if (++openWait > 5) {
failed = true;
failReason = "F1 scene switch never fired";
}
} else {
openWait = 0;
}
return false;
}
bool playerPos(Ogre::Vector3 &out) const
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
out = t.node ? t.node->_getDerivedPosition() : t.position;
return true;
}
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
{
Ogre::Vector3 pos;
if (!playerPos(pos))
return false;
if (pos.distance(expected) > tol) {
failed = true;
failReason = "player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
")";
}
return true;
}
/* The arrival markers face -Z (both scenes keep the same facing
* convention; the exterior arrival_b at (4000.6, ~, 3995.9) faces
* away from the return door); verify the camera took a position
* behind the character (relative to charPos) looking at the walking
* surface, and that the character's visual facing (local +Z) points
* the same way. */
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Quaternion q =
t.node ? t.node->getOrientation() : t.rotation;
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
EditorCamera *ec = app->getEditorCamera();
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
Ogre::SceneNode *camNode =
cam ? cam->getParentSceneNode() : nullptr;
if (!camNode)
return false;
Ogre::Vector3 cp = camNode->_getDerivedPosition();
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
Ogre::Vector3::NEGATIVE_UNIT_Z;
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
charFacing.z > -0.9f) {
failed = true;
failReason =
"not facing the walking surface: camera pos (" +
Ogre::StringConverter::toString(cp.x) + ", " +
Ogre::StringConverter::toString(cp.y) + ", " +
Ogre::StringConverter::toString(cp.z) +
") view (" +
Ogre::StringConverter::toString(vd.x) + ", " +
Ogre::StringConverter::toString(vd.y) + ", " +
Ogre::StringConverter::toString(vd.z) +
") character facing (" +
Ogre::StringConverter::toString(charFacing.x) +
", " +
Ogre::StringConverter::toString(charFacing.z) +
")";
}
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 3) {
app->getRoot()->queueEndRendering();
return true;
}
/* The swings are real-time (doorOpenSpeed deg/s) while a
* headless frame is sub-millisecond, so the door legs need a
* few thousand frames (~10 s wall clock at 1000+ fps); the
* streaming terrain window and the grounding watchdog add a
* few hundred frames on top. */
if (frame > 20000) {
failed = true;
failReason = "timeout waiting for scene switch";
app->getRoot()->queueEndRendering();
return true;
}
switch (phase) {
case 10: {
/* F6: the demo door starts locked (lockedByDefault). */
if (frame < 5)
break;
flecs::entity door = findDoor();
if (!door.is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F6 demo door not found";
}
break;
}
if (!DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason = "F6 demo door not locked at start";
break;
}
/* Emulate the ActuatorSystem E-press on a locked door;
* the inline scene script answers door_locked with
* door_unlock_<doorId>. */
EventBus::getInstance().send("door_locked", "door_id",
doorId);
phase = 11;
break;
}
case 11: {
/* F6: wait for the scene script to unlock the door
* (the frame timeout covers a missing handler). */
if (DoorSystem::isDoorLockedById(doorId))
break;
flecs::entity door = findDoor();
if (!door.is_alive())
break;
door.get_mut<DoorComponent>().toggleRequested = true;
std::cout << "[test] F6 door unlocked via scene script, "
"opening"
<< std::endl;
phase = 12;
break;
}
case 12: {
/* F6: the swing completes and the open state lands in
* the global store. */
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d = door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle)
break;
if (!GlobalStateStore::getInstance().getBool(
"door." + doorId + ".isOpen")) {
failed = true;
failReason = "F6 door open state not persisted";
break;
}
std::cout << "[test] F6 door open and persisted"
<< std::endl;
phase = 0;
break;
}
case 0: {
/* F1: interior -> exterior through the exit scene-switch
* door. */
if (frame < 5)
break;
if (!findDoorById(exitDoorId).is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F1 exit door not found";
}
break;
}
/* F1: every scene-switch door gets the void-side
* tunnel occluder (depth covers the leaf sweep,
* so both swing directions stay visible) plus the
* gap shield backing the leaf/frame clearance
* slits while the door is closed. */
{
const DoorComponent &d =
findDoorById(exitDoorId)
.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 exit door has no tunnel occluder";
break;
}
if (!d.gapShield ||
d.gapShield->getMesh()
->getName()
.find("CellGridDoorOccluderShield") !=
0) {
failed = true;
failReason =
"F1 exit door has no gap shield";
break;
}
/* The door is still closed here: the shield
* backs the slits, the tunnel stays hidden. */
if (!d.gapShield->isVisible() ||
d.occluder->isVisible()) {
failed = true;
failReason =
"F1 closed-door occluder visibility wrong";
break;
}
/* The tunnel walls must reach past the
* leaf/frame clearances (0.02 m sides,
* 0.05 m top) or the void shows between the
* frame and the black corridor while the
* door is open; both meshes are hinge-local,
* so the tunnel footprint must cover at
* least the gap shield's (6 cm margin). */
{
Ogre::Vector3 th =
d.occluder->getMesh()
->getBounds()
.getHalfSize();
Ogre::Vector3 sh =
d.gapShield->getMesh()
->getBounds()
.getHalfSize();
if (th.x < sh.x - 0.001f ||
th.y < sh.y - 0.001f) {
failed = true;
failReason =
"F1 tunnel walls inside the leaf/frame clearances";
break;
}
}
}
if (!requestDoorSwitch(exitDoorId))
break;
phase = 13;
break;
}
case 13: {
/* F1: the scene switch must fire only once the leaf
* is fully open. */
if (waitDoorSwitch(exitDoorId, "exit door",
"demo_scene_exterior.json",
exitOpenWait))
phase = 1;
break;
}
case 1: {
if (!entityExists("x1"))
break;
if (!arrivalDeadline)
arrivalDeadline = frame + 600;
/* The teleport clamps Y against whatever height source is
* available at switch time (analytic baseNoise fallback
* until the pages around the camera stream in), then the
* grounding watchdog re-clamps against the real terrain
* for ~120 frames; poll across frames until the player
* settles at the arrival point instead of checking once. */
Ogre::Vector3 expected = expectedArrivalB();
Ogre::Vector3 pos;
if (!playerPos(pos))
break;
if (pos.distance(expected) > 1.0f) {
if (frame < arrivalDeadline)
break;
failed = true;
failReason =
"player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
"), expected (" +
Ogre::StringConverter::toString(
expected.x) +
", " +
Ogre::StringConverter::toString(
expected.y) +
", " +
Ogre::StringConverter::toString(
expected.z) +
")";
break;
}
if (!checkCameraFacesCenter(expected))
break;
if (failed)
break;
/* The island site must be dry: terrain height at the
* grid XZ above the water surface (Y 6). Doubles as an
* in-engine cross-check of the offline baseNoise mapping
* used to place the exterior content. */
{
TerrainSystem *ts = TerrainSystem::getInstance();
float h = ts && ts->isActive()
? ts->getHeightAt(
ts->worldToRender(4000.0,
0.0,
4000.0))
: 0.0f;
if (h <= 6.0f) {
failed = true;
failReason =
"island site is not dry";
break;
}
}
std::cout << "[test] arrived in the exterior scene at "
"arrival_b, camera faces the walking surface"
<< std::endl;
/* F1: exterior -> interior through the exterior
* scene's own scene-switch door (external doorway
* Z:0:0:0 of its exteriorOnly grid). */
if (!findDoorById(returnDoorId).is_alive()) {
failed = true;
failReason = "F1 return door not found";
break;
}
if (!requestDoorSwitch(returnDoorId))
break;
phase = 14;
break;
}
case 14:
/* F1: same fully-open contract on the way back. */
if (waitDoorSwitch(returnDoorId, "return door",
"demo_scene_interior.json",
returnOpenWait))
phase = 2;
break;
case 2:
if (!entityExists("interrior"))
break;
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
/* F6: the door rebuilt with the scene must be snapped
* back to the persisted open + unlocked state. */
{
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d =
door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle ||
DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason =
"F6 door state not restored after scene switch";
break;
}
std::cout << "[test] F6 door state restored "
"after scene switch"
<< std::endl;
}
/* F1: the scene-switch exit door rebuilt with the
* scene must be closed again - scene-switch doors
* never restore the persisted open state. */
{
flecs::entity exitDoor =
findDoorById(exitDoorId);
if (!exitDoor.is_alive())
break;
const DoorComponent &d =
exitDoor.get<DoorComponent>();
if (d.isOpen || d.currentAngle != 0.0f ||
GlobalStateStore::getInstance().getBool(
"door." + exitDoorId + ".isOpen")) {
failed = true;
failReason =
"F1 exit door not closed after scene switch";
break;
}
std::cout << "[test] F1 exit door closed after "
"scene switch"
<< std::endl;
}
if (!failed) {
std::cout << "[test] arrived back in the interior "
"scene at arrival_a, camera faces the "
"walking surface"
<< std::endl;
std::cout << "[test] PASS" << std::endl;
}
phase = 3;
break;
}
return true;
}
};
/*
* demo-taxi: taxi driver demo. Starts as an exact copy of
* demos/demo-interior-exterior-dynamics (same scene-switch-door setup,
* same streaming-terrain/sky/water exterior); the taxi game (procedural
* town, drivable sedans, passenger/foot-traffic/vehicle-traffic NPCs,
* chained fares) is added step by step - see PLAN.md in this directory.
* Vehicle ideas come from demos/demo-sokoban.
*
* Base description inherited from demo-interior-exterior-dynamics:
* same scene-switching demo as
* demos/demo-scene-switching-extra, but the exterior scene is a real
* outdoor environment instead of a flat colored floor. Two
* hand-authored scenes (demo_scene_interior.json and
* demo_scene_exterior.json), each with a character spawner ("s1",
* character registry ID 2, same character setup as town8.json) and a
* PlayerControllerComponent targeting it.
*
* The interior scene holds a flat colored floor plane with a static
* physics collider plus an "interrior" entity with a CellGridComponent
* in interiorOnly generation mode (a room with floor, ceiling, interior
* walls, windows with opaque glass and an exit door) carrying its own
* ProceduralMaterial + ProceduralTexture; the grid's texture rectangle
* names reference the texture's named rects ("floor" / "ceiling")
* without any Lot/District/Town parent.
*
* The exterior scene holds an exteriorOnly CellGridComponent (entity
* "x1"/"w1" - just the building shell with opaque window glass, seen
* from the outside) standing on a STREAMING terrain entity
* (streamingEnabled, worldSizeUnits 10000 -> 5x5 pages of 2000 units,
* base heights from TerrainComponent::baseNoise: OpenSimplex2 seed 55,
* 3 octaves, frequency 0.0003, amplitude 15 - an archipelago of large
* islands, ~34% land, the rest is ocean). The bounded streaming world
* spans [-1000, 9000] on both axes around the terrain entity at the
* origin (world (0,0) is half a page in from the corner, NOT the world
* center), so all exterior content is placed near the world center on
* an island with more than 500 units of dry land in every direction:
* the grid at (4000, 10.0148, 4000) with its floor exactly on the
* terrain surface, arrival_b at (4000.6, 9.9905, 3995.9) 3 units
* outside (-Z of) the return door Z:0:0:0, spawner s1 at (4000,
* 9.9039, 3975) - all Y values probed from the engine's
* TerrainSystem::getHeightAt. The "sky" entity (skybox + sun
* components) sits at (4000, 10, 4000): EditorSunSystem parents the
* sun/moon sphere nodes to the root node and orbits them around the
* camera (orbit radius 200), so they always stay inside the
* camera-following skybox cube and line up with the shader sun/moon
* discs, but the light node stays a child of the sky entity's
* transform node, so the entity should still sit near the content for
* sensible shadow placement (town8.json keeps its sky entity at the
* origin next to its content). A "water"
* entity (waterPlane + waterPhysics components, the buoyancy setup of
* town8.json, planeSize 12000 centered on the content) completes the
* scene; the water surface at Y 6 turns everything below into ocean
* floor while the content island (terrain ~10 at the site) stays dry.
* The shipped heightmaps/4242424300000001/heightmap.bin (generated by
* gen_heightmap.py) is still staged - unused in streaming mode, it keeps
* flipping streamingEnabled off a working fallback.
*
* F6 demo content: the interior grid's internal doorway Z:0:0:8 is
* configured (doorConfigs in demo_scene_interior.json) as persistent +
* lockable + locked by default, with a fixed gridUid so its global door
* ID is stable, and the grid entity carries an inline scene script that
* answers the "door_locked" event with "door_unlock_<doorId>" (the door
* event contract; the door never opens by itself, the player presses E
* again). The door's locked and open/closed state lives in the global
* state store and survives the interior -> exterior -> interior scene
* switches.
*
* F1 demo content: both scene transitions go through scene-switch doors.
* The interior grid has the external exit doorway Z:0:0:15 configured as
* a scene-switch door to demo_scene_exterior.json (target arrival_b);
* the exterior grid sets
* doorSceneSwitchPath/demo_scene_interior.json +
* doorSceneSwitchTarget/arrival_a grid-wide, so its external doorway
* Z:0:0:0 is the way back. E on such a door swings the leaf open first;
* the scene switch fires only when the leaf reaches the open angle, and
* a black occluder box behind the doorway hides the missing half of the
* building while it swings; while the door is closed a flat black gap
* shield behind the leaf blacks out the leaf/frame clearance slits, so
* no sky/terrain bleeds through the closed door. Both scenes carry
* their own player
* controller, so each switch is a clean takeover (see
* EditorApp::performSceneSwitch); travel back and forth is endless.
*
* The mouse is grabbed while Playing (built-in game-mode behaviour);
* Escape toggles the pause menu, which frees the cursor.
*
* The binary is self-contained in its build directory: run it from there
* (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the
* runtime config JSONs are staged next to it by the build; both scene
* JSONs are symlinks into the source tree, so scene edits take effect
* without a rebuild; the terrain heightmap is staged to
* heightmaps/4242424300000001/ by configure_file, re-copied automatically
* when the source file changes).
*
* Extra flags:
* --test-switch headless-friendly end-to-end check: verifies the F6
* locked door (locked at start, unlocked by the scene
* script through the door event contract, open state
* persisted and restored across the scene switches) and
* both F1 scene-switch doors (interior -> exterior and
* back each fire only when the leaf is fully open,
* black occluder tunnel + closed-door gap shield
* present with the right closed-state visibility),
* verifying the round-trip
* arrival teleports (the exterior arrival check reads
* the expected Y from TerrainSystem::getHeightAt and
* polls while the streaming window loads and the
* grounding watchdog re-clamps); exits 0 on PASS.
*/
int main(int argc, char *argv[])
{
try {
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
/* Release binary: project parameters are embedded at build
* time from project.json (generated project.h); the project
* root is the working directory the binary runs from. */
DemoApp app(EDITSCENE_PROJECT_APP_NAME);
{
ProjectConfig cfg;
cfg.rootDir =
std::filesystem::current_path().string();
cfg.appName = EDITSCENE_PROJECT_APP_NAME;
cfg.startScene = EDITSCENE_PROJECT_START_SCENE;
cfg.gameMode = EDITSCENE_PROJECT_GAME_MODE;
cfg.loaded = true;
app.setProjectConfig(cfg);
}
#else
DemoApp app;
#endif
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
bool testSwitch = false;
bool sceneArgGiven = false;
Ogre::String shotFile;
int shotFrame = -1;
bool forcePos = false;
Ogre::Vector3 forcePosVec = Ogre::Vector3::ZERO;
float forcePosYaw = 0.0f;
float forcePosPitch = 0.0f;
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE;
#else
Ogre::String sceneFile = "demo_scene_interior.json";
#endif
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg == "--test-switch") {
testSwitch = true;
} else if (arg == "--debug-buoyancy") {
app.setDebugBuoyancy(true);
} else if (arg == "--screenshot" && i + 2 < argc) {
shotFrame = atoi(argv[i + 1]);
shotFile = argv[i + 2];
i += 2;
} else if (arg == "--force-pos" && i + 4 < argc) {
forcePos = true;
forcePosVec = Ogre::Vector3(
(float)atof(argv[i + 1]),
(float)atof(argv[i + 2]),
(float)atof(argv[i + 3]));
forcePosYaw = (float)atof(argv[i + 4]);
i += 4;
/* Optional pitch (degrees); only consumed when
* the next token is plainly numeric, so the
* positional scene file is never eaten. */
if (i + 1 < argc) {
char *end = nullptr;
float p = (float)strtod(argv[i + 1],
&end);
if (end && *end == '\0' &&
end != argv[i + 1]) {
forcePosPitch = p;
i += 1;
}
}
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
sceneArgGiven = true;
}
}
app.setHeadless(headless);
app.initApp();
if (headless) {
/* Headless mode never creates EditorUISystem, which
* owns the CharacterRegistry singleton; the demo scene
* has a character spawner, so provide a bare registry
* to keep spawner resolution from asserting. The
* character then falls back to an inline spawn without
* a physics capsule (fine for a smoke run). */
static CharacterRegistry s_characterRegistry;
s_characterRegistry.setWorld(app.getWorld());
s_characterRegistry.setSceneManager(app.getSceneManager());
s_characterRegistry.initialize();
}
/* Meshes + materials referenced by the scene entities. */
createDemoResources();
/* With an embedded project, EditorApp::setup() skipped the
* startup menu; the start scene defaults to the embedded
* one and can still be overridden positionally. */
(void)sceneArgGiven;
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, E = use door, Escape = pause menu"
<< std::endl;
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame && !testSwitch)
app.getRoot()->addFrameListener(&exitListener);
SceneSwitchTestListener testListener(&app);
if (testSwitch)
app.getRoot()->addFrameListener(&testListener);
DebugCaptureListener captureListener(&app);
if (forcePos) {
captureListener.teleportEnabled = true;
captureListener.teleportPos = forcePosVec;
captureListener.teleportYawDeg = forcePosYaw;
captureListener.teleportPitchDeg = forcePosPitch;
}
if (shotFrame > 0 && !shotFile.empty())
captureListener.shotFrame = shotFrame;
captureListener.shotFile = shotFile;
if (forcePos || captureListener.shotFrame > 0)
app.getRoot()->addFrameListener(&captureListener);
app.getRoot()->startRendering();
if (testSwitch) {
if (testListener.failed) {
std::cerr << "[test] FAIL: "
<< testListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (testListener.phase != 3) {
std::cerr << "[test] FAIL: incomplete"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
/* Destroy scene entities while the systems are still alive:
* CharacterSpawnerSystem registers an OnRemove observer on
* CharacterSpawnerComponent that dereferences the system, and
* the flecs world only dies after destroyEditorSystems() in
* ~EditorApp, so letting the spawner entity live that long
* would call back into a destroyed system. */
app.clearScene();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
@@ -0,0 +1,771 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 488,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 4000.60009765625,
"y": 9.990500450134277,
"z": 3995.89990234375
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [
{
"cellGrid": {
"ceilingRectName": "",
"cellHeight": 4.0,
"cellSize": 2.0,
"cells": [
{
"flags": 37757955,
"x": -1,
"y": 0,
"z": 0
},
{
"flags": 2097667,
"x": 0,
"y": 0,
"z": 0
},
{
"flags": 41953283,
"x": 1,
"y": 0,
"z": 0
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 1
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 1
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 1
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 2
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 2
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 2
},
{
"flags": 20976643,
"x": -1,
"y": 0,
"z": 3
},
{
"flags": 16781315,
"x": 0,
"y": 0,
"z": 3
},
{
"flags": 25171971,
"x": 1,
"y": 0,
"z": 3
}
],
"depth": 10,
"doorActionName": "",
"doorConfigs": {},
"doorMeshName": "",
"doorOpenAngle": 100.0,
"doorOpenSpeed": 180.0,
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_interior.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": false,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
"extWallRectName": "",
"extWindowFrameRectName": "",
"floorRectName": "",
"friction": 0.5,
"furnitureCells": [],
"generationMode": "exteriorOnly",
"generationScript": "",
"glassColor": [
0.4000000059604645,
0.6000000238418579,
0.800000011920929,
0.3499999940395355
],
"glassMaterialName": "",
"glassReflectivity": 0.800000011920929,
"gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78",
"height": 1,
"intDoorFrameRectName": "",
"intWallRectName": "",
"intWindowFrameRectName": "",
"roofSideRectName": "",
"roofTopRectName": "",
"width": 10
},
"children": [
{
"children": [],
"clearArea": {
"clearCells": true,
"clearFurniture": true,
"clearRoofs": false,
"clearRooms": false,
"maxX": 10,
"maxY": 1,
"maxZ": 10,
"minX": -10,
"minY": 0,
"minZ": -10
},
"id": 491,
"name": {
"name": "c1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 492,
"name": {
"name": "r1"
},
"room": {
"connectedRoomIds": [],
"createCeiling": true,
"createFloor": true,
"createInteriorWalls": true,
"createWindows": true,
"exits": [
true,
false,
false,
false
],
"fillRoomWithFurniture": false,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
"maxY": 1,
"maxZ": 4,
"minX": -1,
"minY": 0,
"minZ": 0,
"persistentId": "room_1788689581735260047_499",
"roomType": "",
"tags": []
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 490,
"name": {
"name": "w1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 489,
"name": {
"name": "x1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.014800071716309,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 493,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
"diffuseColor": {
"a": 1.0,
"b": 1.0,
"g": 1.0,
"r": 1.0
},
"direction": {
"x": 0.30000001192092896,
"y": -1.0,
"z": 0.20000000298023224
},
"intensity": 1.0,
"lightType": "directional",
"linearAttenuation": 0.0,
"quadraticAttenuation": 0.0,
"range": 100.0,
"specularColor": {
"a": 1.0,
"b": 0.5,
"g": 0.5,
"r": 0.5
},
"spotlightFalloff": 1.0,
"spotlightInnerAngle": 30.0,
"spotlightOuterAngle": 45.0
},
"name": {
"name": "demo_light"
},
"transform": {
"position": {
"x": 4000.0,
"y": 20.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 494,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 9.903900146484375,
"z": 3975.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 495,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 496,
"name": {
"name": "terrain"
},
"terrain": {
"auxMaps": [],
"baseNoise": {
"amplitude": 15.0,
"frequency": 0.0003000000142492354,
"lacunarity": 2.0,
"octaves": 3,
"persistence": 0.5,
"seed": 55
},
"blendMapSize": 1024,
"compositeMapDistance": 300.0,
"detailNoise": {
"amplitude": 10.0,
"enabled": false,
"frequency": 0.006000000052154064,
"lacunarity": 2.0,
"octaves": 4,
"persistence": 0.5,
"seed": 26368
},
"enabled": true,
"farClipDistance": 6000.0,
"fogEnabled": true,
"fogEnd": 5500.0,
"fogStart": 2500.0,
"heightmapFile": "heightmap.bin",
"heightmapSize": 256,
"layers": [
{
"diffuseTexture": "Ground23_col.jpg",
"name": "Base",
"normalTexture": "Ground23_normheight.dds",
"worldSize": 100.0
},
{
"diffuseTexture": "Ground37_diffspec.dds",
"name": "Layer 1",
"normalTexture": "Ground37_normheight.dds",
"worldSize": 100.0
}
],
"maxBatchSize": 65,
"maxPixelError": 1.0,
"minBatchSize": 17,
"pageHoldRadius": 3,
"pageLoadRadius": 2,
"roadConfig": {
"laneWidth": 3.0,
"lanesPerDirection": 1,
"prefabDespawnDistance": 250.0,
"prefabSpawnDistance": 150.0,
"roadLodDistance": 200.0,
"roadMaterialName": "RoadMaterial",
"roadMeshTemplate": "road_segment.mesh",
"roadThickness": 0.30000001192092896,
"roadVisibilityDistance": 1000.0,
"sidewalkEnabled": false,
"sidewalkHeight": 0.15000000596046448,
"sidewalkMeshTemplate": "",
"sidewalkThickness": 0.30000001192092896,
"sidewalkWidth": 1.5
},
"streamingEnabled": true,
"terrainId": 4242424300000001,
"terrainSize": 65,
"worldSize": 2000.0,
"worldSizeUnits": 10000.0
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 497,
"name": {
"name": "sky"
},
"skybox": {
"cloudiness": 0.0,
"dayBottomColor": [
0.6000000238418579,
0.800000011920929,
1.0
],
"dayTopColor": [
0.20000000298023224,
0.5,
1.0
],
"enabled": true,
"moonSize": 0.029999999329447746,
"nightBottomColor": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"nightTopColor": [
0.0,
0.0,
0.05000000074505806
],
"size": 443.0,
"starsEnabled": false,
"sunSize": 0.05000000074505806,
"sunriseColor": [
1.0,
0.5,
0.20000000298023224
],
"sunsetColor": [
1.0,
0.30000001192092896,
0.10000000149011612
]
},
"sun": {
"ambientDay": [
0.30000001192092896,
0.30000001192092896,
0.30000001192092896
],
"ambientNight": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"ambientSunrise": [
0.30000001192092896,
0.20000000298023224,
0.15000000596046448
],
"ambientSunset": [
0.25,
0.15000000596046448,
0.10000000149011612
],
"castShadows": true,
"enabled": true,
"intensity": 1.7899999618530273,
"moonColor": [
0.30000001192092896,
0.30000001192092896,
0.5
],
"moonSphereSize": 3.4000000953674316,
"orbitTilt": 15.0,
"showMoonSphere": true,
"showSunSphere": true,
"sunColor": [
1.0,
0.949999988079071,
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 8.35942554473877,
"timeSpeed": 0.12999999523162842
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 498,
"name": {
"name": "water"
},
"transform": {
"position": {
"x": 4000.0,
"y": 0.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"waterPhysics": {
"defaultAngularDrag": 0.05000000074505806,
"defaultBuoyancy": 1.0,
"defaultLinearDrag": 0.25,
"defaultSubmergedThreshold": 0.10000000149011612,
"enabled": true,
"gravity": 9.8100004196167,
"waterDensity": 1000.0,
"waterSurfaceY": 6.0
},
"waterPlane": {
"autoUpdateFromWaterPhysics": true,
"enabled": true,
"planeSize": 12000.0,
"reflectivity": 0.3799999952316284,
"renderTextureSize": 512,
"tiling": 0.012000000104308128,
"waterColor": [
0.0,
0.30000001192092896,
0.5,
0.800000011920929
],
"waterSurfaceY": 6.0,
"waveScale": 0.03099999949336052,
"waveSpeed": 0.9800000190734863
}
}
],
"version": "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
streaming mode, where base heights come from the terrain's baseNoise
(FastNoiseLite) and this file is NOT sampled — it is only staged next to
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
finds it. This script documents the historical non-streaming layout and
is kept as the generator of record for that staged file.
Legacy description (non-streaming mode):
The exterior scene's terrain entity (terrainId 4242424300000001,
non-streaming, worldSize 500 per page) loads its base heights from
heightmaps/4242424300000001/heightmap.bin via
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
then res*res little-endian float32 heights.
Height profile: a flat disc at y=0 around the origin (the playable area
with the building shell and the scene-switch arrival point), sloping
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
as a lake around the centre.
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
loads a fixed 3x3 page grid, so the heightmap covers the physical world
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
the height at physical coordinate (-ws + i * 3*ws / res). Physical
space maps to visual (scene) space by a half-page shift on X and a
per-page Z mirror; this script inverts that mapping so every texel gets
the height of the *visual* position it will be rendered at, then
verifies the round trip by re-sampling the generated grid the same
bilinear way the engine does.
"""
import math
import struct
import sys
RES = 256 # must match "heightmapSize" in the scene JSON
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
EDGE_RADIUS = 160.0 # slope reaches full depth here
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
def profile(r):
"""Visual height at distance r from the origin."""
if r <= FLAT_RADIUS:
return 0.0
if r >= EDGE_RADIUS:
return DEPTH
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
t = t * t * (3.0 - 2.0 * t) # smoothstep
return DEPTH * t
def physical_to_visual(px, pz):
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
vx = px - WORLD_SIZE * 0.5
page = math.floor(pz / WORLD_SIZE)
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
return vx, vz
def visual_to_physical(vx, vz):
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
px = vx + WORLD_SIZE * 0.5
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
return px, pz
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
span = 3.0 * WORLD_SIZE
origin = -WORLD_SIZE
grid = []
for iz in range(RES):
row = []
pz = origin + iz * span / RES
for ix in range(RES):
px = origin + ix * span / RES
vx, vz = physical_to_visual(px, pz)
row.append(profile(math.hypot(vx, vz)))
grid.append(row)
with open(out_path, "wb") as f:
f.write(struct.pack("<I", RES))
for row in grid:
f.write(struct.pack("<%df" % RES, *row))
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
# (bilinear, physical coords) at a few visual positions.
def sample(vx, vz):
px, pz = visual_to_physical(vx, vz)
fx = (px - origin) / span * RES
fz = (pz - origin) / span * RES
x0 = max(0, min(int(math.floor(fx)), RES - 1))
z0 = max(0, min(int(math.floor(fz)), RES - 1))
x1 = max(0, min(x0 + 1, RES - 1))
z1 = max(0, min(z0 + 1, RES - 1))
tx = fx - int(math.floor(fx))
tz = fz - int(math.floor(fz))
h00 = grid[z0][x0]
h10 = grid[z0][x1]
h01 = grid[z1][x0]
h11 = grid[z1][x1]
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
(1 - tx) * tz * h01 + tx * tz * h11)
checks = [
("arrival (0,24)", 0.0, 24.0),
("origin (0,0)", 0.0, 0.0),
("building corner (4,30)", 4.0, 30.0),
("flat edge (55,0)", 55.0, 0.0),
("mid slope (110,0)", 110.0, 0.0),
("lake (200,0)", 200.0, 0.0),
("lake (-200,200)", -200.0, 200.0),
]
ok = True
for label, vx, vz in checks:
h = sample(vx, vz)
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
% (label, vx, vz, h))
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
if abs(sample(vx, vz)) > 1e-4:
print("ERROR: playable area not flat at", vx, vz)
ok = False
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
if sample(vx, vz) > -4.9:
print("ERROR: lake area not deep enough at", vx, vz)
ok = False
if not ok:
sys.exit(1)
print("heightmap written to %s (%dx%d), all checks passed"
% (out_path, RES, RES))
if __name__ == "__main__":
main()
Binary file not shown.
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,4 @@
{
"fontPath": "Jupiteroid-Bold.ttf",
"fontSize": 16.0
}
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-taxi",
"startScene": "demo_scene_interior.json",
"gameMode": true
}
@@ -0,0 +1,65 @@
# Stage everything demoTaxi needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-taxi.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# Small demo-owned files are COPIED, except the demo scenes, which are
# SYMLINKED to the source tree so scene edits are visible to the demo
# without a rebuild (the demo never saves scenes, so nothing writes through
# the links); the big pre-staged runtime directories
# are SYMLINKED from the editScene binary directory (Ogre FileSystem
# locations follow symlinks, and copying would duplicate hundreds of MB on
# every build). The symlink targets are populated by the editSceneEditor
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
foreach(f demo_scene_interior.json demo_scene_exterior.json)
set(link "${DEMO_DIR}/${f}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE "${link}")
endif()
file(CREATE_LINK "${SRC_DIR}/${f}" "${link}" SYMBOLIC)
endforeach()
# Terrain heightmap: staged at configure time by a configure_file COPYONLY
# in CMakeLists.txt (to heightmaps/4242424300000001/), which re-copies it
# automatically when the source file changes. Not symlinked: the fixup
# layer and save path write next to it.
# Character prefab for the spawner's registry entry (registryId 2). It is
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest — the demo needs
# character_registry.json for the spawner's registryId 2 and
# animation_tree.json for the character's "male1_6" animation tree). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()