Scheduled driving

This commit is contained in:
2026-10-24 16:38:53 +03:00
parent 95b42090f9
commit 9e2d9fce59
15 changed files with 2001 additions and 852 deletions
+102 -50
View File
@@ -319,6 +319,11 @@ cd demos/demo-taxi
# (no entity, no high-detail tier), the curb wait works entity-less and
# cancelling the goal hands the NPC back to its schedule:
./demoTaxi --headless --test-taxi-lowdetail
# ...or headless schedule-adherent traffic probe (phase 6.A): scrubs the
# clock to a driver's work leg — parked at home before departure, boards
# and drives when observed, symbolic advance when not, parked at the
# destination parking afterwards, evening leg drives home:
./demoTaxi --headless --test-traffic-schedule
# ...or probe natural (non-forced) high-detail spawns from a given player
# position; prints how many population NPCs are spawned plus per-NPC
# entity/master-mesh/age/sex diagnostics at frame N:
@@ -581,6 +586,15 @@ animation (when the player is driving, `VehicleControllerSystem` parks the
locomotion state machine in idle and `PlayerControllerSystem` skips
locomotion via `PlayerControllerComponent::driving`).
The NPC chain runs between `npcPopulation` and the terrain prefab spawners,
in this exact order: `npcTraffic` -> `npcMotion` -> `npcTaxi` ->
`npcDetail` -> `npcAvoidance`. Traffic first because its clock-pure
evaluation (un)suspends drivers and the motion layer must see the fresh
suspension flag in the SAME frame (demo-taxi 6.A update-order note);
motion before taxi so taxi can override suspended NPCs; detail after both
so spawns plant at fresh symbolic positions and its `driveHighDetail` sees
fresh taxi/traffic drive targets.
## Important Subsystems
### PlayerControllerSystem
@@ -737,7 +751,10 @@ the clock (pause/speed/time-of-day controls), the population NPCs grouped by
town area with the schedule entry active at the current time, and the
selected NPC's full 24h schedule + home/work/interest bindings, plus a "Path"
pane (the active taxi-goal path first, else the motion path, as a waypoint
list) and a "Locations" pane (the area's `TownLocations::LocationSet`). While the
list) and a "Locations" pane (the area's `TownLocations::LocationSet`). NPCs
with an active taxi goal are marked `[TAXI state]`, traffic drivers get a
`[CAR]` marker with a per-car section (parked-at, phase, active leg,
high-detail state). While the
overlay is open the mouse is ungrabbed so its widgets are reachable; **F7**
or **ESC** closes it and grabs the mouse again — ESC never opens the pause
menu while the overlay is up (overlay closes first, next ESC pauses). The
@@ -790,9 +807,15 @@ the traffic lanes. `motionInfoOf(registryId)` is the read API; the F7
schedule overlay shows the motion state (AT location / IN_TRANSIT -> dest
with progress) and the symbolic position.
`setSuspended(registryId, true)` hands an NPC to an external controller
(NpcTaxiSystem): suspended NPCs are skipped by the update loop and keep
no motion state, and clearing the suspension lets the next clock-pure
evaluation re-own the NPC (it snaps to where the schedule says).
(NpcTaxiSystem, NpcTrafficSystem): suspended NPCs are skipped by the
update loop and keep no motion state, and clearing the suspension lets
the next clock-pure evaluation re-own the NPC (it snaps to where the
schedule says). Shared helpers so the walk and drive layers can never
disagree (demo-taxi 6.A): `nextLocationChangeOf()` (the next schedule
entry that changes location), `walkTravelHours()` (pure travel-time
query through the same path cache — NpcTrafficSystem uses it to derive
the walk layer's would-be departure for the drive-suppression window),
`roadGraph()` / `pathBetween()` (the cached path itself).
Headless verification:
`demoTaxi --headless --test-motion` (full-day clock sweep at 20 cm/s to
widen transit windows; asserts transit sightings, day movement and bit-exact
@@ -850,7 +873,11 @@ While HIGH, `driveHighDetail` re-aims the path target at the want location
unless `NpcTaxiSystem` owns the NPC (taxi goal), in which case the target
is `NpcTaxiSystem::driveTarget` (pickup curb / vehicle door point) with
`NpcTaxiSystem::remainingPath` as the polyline, and riding NPCs stand
still (their node is seat-pinned by the taxi system);
still (their node is seat-pinned by the taxi system), or the NPC is a
traffic driver walking to its car (demo-taxi 6.A), in which case the
target is `NpcTrafficSystem::driveTarget` (the driver-door point);
seated/driving traffic drivers are not spawn-eligible as pedestrians at
all (the traffic system owns their entity via its own driver spawner);
target and path are converted world -> render through `RenderOriginSystem`
(PathFollowingSystem compares against render-space node positions). While
IN_TRANSIT the symbolic road polyline past the current progress
@@ -934,7 +961,9 @@ the vendored OGRE repo (see PLAN.md for usage).
`NpcTaxiSystem` (EditorApp-owned singleton `getInstance()`, game mode only,
`systems/NpcTaxiSystem.hpp/.cpp`) runs NPC taxi rides:
`assignTaxiGoal(registryId, destLocation)` suspends the schedule layer
(`NpcMotionSystem::setSuspended`) and walks the NPC through WalkToCurb ->
(`NpcMotionSystem::setSuspended`) — refused for NPCs the traffic system
drives or currently seats (`NpcTrafficSystem::isDriving`/`isSeated`) — and
walks the NPC through WalkToCurb ->
WaitAtCurb -> Boarding -> Riding -> WalkToDestination — walk the
road-graph path to the nearest curb, wait for a stopped (< `stoppedSpeedMps`
0.6 m/s) vehicle within `pickupRadiusM` (6 m) with a free non-driver seat
@@ -1020,7 +1049,9 @@ collection time (avoidance rewrites `pf.targetPosition` during the
frame, so reading it back would measure the detour itself).
Taxi-goal NPCs skip the fan and the road escape:
their boarding walk intentionally approaches the car door ON the
carriageway.
carriageway. The same exemption covers traffic drivers in their
walk-to-car phase (demo-taxi 6.B — their own parked car is the goal,
not an obstacle); once seated they are the traffic system's business.
Road clearing (4.9.4, until proper road crossings exist): an NPC on the
carriageway (`RoadPath::nearestEdgeInfo` lateral distance inside the
@@ -1107,11 +1138,14 @@ cross. The AI components are attached 3 frames AFTER the teleport so
the waypoint cursor cannot skip the junction point from the stale
pre-teleport position.
### NpcTrafficSystem (demo-taxi phase 5.4)
### NpcTrafficSystem (demo-taxi phase 5.4 + 6.A)
`NpcTrafficSystem` (EditorApp-owned singleton `getInstance()`, game mode
only, `systems/NpcTrafficSystem.hpp/.cpp`, update after
NpcAvoidanceSystem, `postPhysicsUpdate` right after NpcTaxiSystem's)
only, `systems/NpcTrafficSystem.hpp/.cpp`, update BETWEEN the population
and motion systems — evalSymbolic (un)suspends drivers and the motion
layer must see the fresh flag in the SAME frame, otherwise the frame a
drive leg ends leaves the registry position at the last symbolic car
position for one frame; `postPhysicsUpdate` right after NpcTaxiSystem's)
owns the population's car fleet. When an area's population and
`TownLocations::LocationSet` exist, each Parking location rolls a
deterministic hash (fnv1a of the location name, the TownLocations
@@ -1122,52 +1156,70 @@ on-street parking. The driver is the first adult (ageYears >= 18)
whose `npcHome` string column matches the parking spot's address.
Parked cars sit nose along the nearest road edge
(`RoadPath::nearestEdgeInfo`, deterministic direction bit) with the
handbrake on and no driver; a deterministic `cruisingRatio` subset
drives an endless loop (home -> deterministic other Parking/Curb ->
home) built from `RoadPath::findPath` + `RoadPath::offsetToRightLane`
(rightmost-lane offset: carriageway half width minus half a lane,
RIGHT = forward x up in OGRE's right-handed +Y-up world; off-road curb
endpoints are first snapped onto the nearest carriageway point so the
car never leaves the road to touch the exact curb) and
junction-classified via `AiDriverSystem::classifyJunctions`. Cruising
drivers are suspended from NpcMotionSystem and their registry position
follows the car (the walk-vs-drive choice is deferred to phase 6, so a
cruising driver effectively lives in its car).
handbrake on and no driver.
Schedule-adherent driving (6.A, replaced the 5.4 endless cruising
loop): `evalSymbolic` re-derives each car's state from
`GameClockSystem::getAbsoluteHours()` every frame — PURE, so clock
scrubs (even backwards), pausing and scene reloads evaluate
identically. The car's parked location chains through the day's
completed drive legs starting from the driver's home spot. Per
schedule leg (`NpcMotionSystem::nextLocationChangeOf` /
`walkTravelHours` — shared helpers so both layers never disagree) the
driver drives only when the car is parked at the leg origin AND the
walk is unreasonable (> `maxWalkDistanceM` 300 m or longer than the
schedule span). Drive departure is anchored so the car ARRIVES when
the next entry starts (leg routes from `RoadPath::findPath` +
`offsetToRightLane`, junction-classified, cached per parking pair in a
bounded map flushed on graph version change). Phases: waiting at the
origin (foot walk suppressed from the walk layer's own would-be
departure, derived purely through the motion layer's path cache),
WalkToCar (symbolic doorstep->car lerp), Driving (symbolic position
advances along the route at `cruiseSpeed`). **Purity rule:
`rec->position` is always clock-derived — the live tier NEVER writes
it back** (no live walker/car positions, no alight-position persist);
live entities are visual approximations only.
Two tiers, symbolic ground truth (same design as NpcDetailSystem): the
`TrafficCar` record (route + progress in meters) is authoritative and
survives spawn/despawn and scene switches; the high-detail tier (camera
within 150 m, hysteresis 220 m — the taxi demo tunes these to 110/165 m
in demo_main.cpp for batch budget —, dwell 3 s, spawn budget 1/frame, cap
`maxActiveVehicles` 8) instantiates `prefabs/sedan-traffic.json` (demo
source tree, extracted from the vehicletest scene's sedan1 by
`extract_sedan_prefab.py`, configure_file-staged like the heightmap)
via a locally constructed `PrefabSystem`, removes the
EditorMarkerComponent `createInstance` adds (runtime-only: not
selectable, never serialized), attaches the junction-classified route
as an AiDriverComponent with `routeIndex` from the symbolic progress,
and seats the driver through the same manual-spawner +
`postPhysicsUpdate` seat-pinning approach as NpcTaxiSystem — plus an
AnimationTreeComponent added at seat time when the inline-spawned
character lacks one (tree name from
`TrafficCar` record survives spawn/despawn and scene switches; the
high-detail tier (camera within 150 m, hysteresis 220 m — the taxi demo
tunes these to 110/165 m in demo_main.cpp for batch budget —, dwell
3 s, spawn budget 1/frame, cap `maxActiveVehicles` 8) instantiates
`prefabs/sedan-traffic.json` (demo source tree, extracted from the
vehicletest scene's sedan1 by `extract_sedan_prefab.py`,
configure_file-staged like the heightmap) via a locally constructed
`PrefabSystem`, removes the EditorMarkerComponent `createInstance`
adds (runtime-only: not selectable, never serialized), attaches the
junction-classified leg route as a non-looping AiDriverComponent with
`routeIndex` from the live car position, and seats the driver through
the same manual-spawner + `postPhysicsUpdate` seat-pinning approach as
NpcTaxiSystem — plus an AnimationTreeComponent added at seat time when
the inline-spawned character lacks one (tree name from
`NpcDetailSystem::animationTreeName`), with the "driving" locomotion
state re-asserted every `postPhysicsUpdate` frame (the spawn-frame
setState no-ops before AnimationTreeSystem has per-entity state; the
re-assert is an early-out once latched). On despawn
the live vehicle position projects back onto the route progress.
re-assert is an early-out once latched). Observed physics is
best-effort: the AI can park AFTER the symbolic clock already chained
into the next leg (late board, junction yield), so the alight check
lives outside the Driving phase and fires whenever the AI reports
arrived; when the symbolic layer is driving another leg by then the
car despawns to resync. On despawn nothing is persisted back — the
next spawn reads the clock-pure symbolic state.
`NpcDetailSystem::isSpawnEligible` consults
`NpcTrafficSystem::isDriving` so a suspended cruising driver never
materializes as a pedestrian (suspension erases the motion state, which
would otherwise read as eligible). A car the player enters
(PlayerControlledComponent — carjacking) leaves the fleet: the record
is erased, the driver alights in place and returns to its schedule.
Symbolic cars are invisible and cannot collide — junction priority only
exists in the high-detail tier, so a transient overlap at the tier
boundary is accepted. Headless probe: `--test-traffic-town` (exterior
scene): waits for the fleet, teleports the player next to the first
cruiser, the car must spawn and drive, a far teleport must despawn it
while the symbolic progress keeps advancing, and teleporting back must
respawn it.
`NpcTrafficSystem::isDriving`/`isSeated` so a suspended or seated
driver never materializes as a pedestrian, and `NpcTaxiSystem` refuses
goals for both (a taxi goal would fight the traffic system over the
NPC). A car the player enters (PlayerControlledComponent —
carjacking) leaves the fleet: the record is erased, the driver alights
in place and returns to its schedule. Symbolic cars are invisible and
cannot collide — junction priority only exists in the high-detail
tier, so a transient overlap at the tier boundary is accepted.
Headless probe: `--test-traffic-schedule` (exterior scene): scrubs the
clock to a driver's work leg — car parked at home before departure,
driver boards and the car drives when observed, symbolic tier advances
when not, car parked at the destination parking after arrival with the
driver back on schedule, evening return leg drives home (replaces the
5.4 `--test-traffic-town` cruising probe).
### EditorSkyboxSystem / EditorSunSystem
+15 -9
View File
@@ -2516,6 +2516,21 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
stepProfile("npcPopulation");
/* --- NPC vehicle traffic (demo-taxi phase 5.4/6.A): fleet
* seeding, clock-pure symbolic drive legs, distance-gated
* spawn/despawn. Runs after the population (needs the
* locations and registry fresh) and BEFORE the motion
* update: evalSymbolic (un)suspends drivers, and the
* motion layer must see the fresh suspension flag in the
* SAME frame — otherwise the frame a drive leg ends leaves
* 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) {
m_npcTrafficSystem->update(fixedEvt.timeSinceLastFrame);
}
stepProfile("npcTraffic");
/* --- NPC motion (game mode: symbolic schedule-driven
* movement of the population; editor: no-op) --- */
if (m_npcMotionSystem) {
@@ -2554,15 +2569,6 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
stepProfile("npcAvoidance");
/* --- NPC vehicle traffic (demo-taxi phase 5.4): fleet
* seeding, symbolic cruise advance, distance-gated
* spawn/despawn. Runs after the population/motion/detail
* systems so the locations and registry are fresh --- */
if (m_npcTrafficSystem) {
m_npcTrafficSystem->update(fixedEvt.timeSinceLastFrame);
}
stepProfile("npcTraffic");
/* --- Terrain prefab spawners (after terrain so spawn Y-snap
* and compliance see fresh terrain data) --- */
if (m_terrainPrefabSpawnerSystem) {
+151 -43
View File
@@ -3126,6 +3126,10 @@ road-editing UX and town-area ownership:
New headless probes: ownership identical under reversed area order,
ownerAreaIndex containment/coverage/seed/name tie-breaks
(`road_geometry_overlap_test` PASSED).
Fix (2026-10-23): the ownership legend's per-area `ColorButton`
swatches all shared the `"##owncolor"` ImGui ID (no `PushID` scope
in the loop) — ImGui flagged an ID conflict when the preview was
enabled; each legend row is now `PushID(ai)`/`PopID`-scoped.
3. **Per-generation road make-up (2026-10-23).** `GridParams` gained
`lanesAtoB`/`lanesBtoA` (0 = leave the edge field unset, the
`RoadConfig` defaults apply at resolve time) and
@@ -3141,50 +3145,154 @@ road-editing UX and town-area ownership:
### Phase 6 — Foot traffic NPCs
This part concerns asset pipeline in assets/blender/characters in addition to the code.
Makes the town population behave like real townsfolk: people follow
their daily schedules on foot or in their own cars, and look right at
every distance. Part of the work is asset pipeline
(assets/blender/characters), part is code.
- Character asset system changes:
- Need a system to use separate body parts for truly nude top and bottom keeping existing ones for body part composition with clothes (with fallback to current nude body parts).
- Need to implement LOD body parts to reduce number of batches. First candidate is face.
- Need to implement texture atlas generation for 20m-distant NPCs.
- There is an idea to use Ogre's manual LODs as the following:
- at default LOD0 distance is < 10m, body is modular, highly detailed, materials a bit optimized, but not at cost of quality.
- LOD1, distance >= 10m and < 20m body is modular and highly detailed, 1 material per body part (the same style as with cars, material is the same for all body parts), uses texture atlas of 2048x2048 with separate textures, the face is replaced by face mask which has single material from the same atlas, shape keys removed.
- LOD2, distance >= 20m and < 60m, like with LOD1 but combinations of possible body parts and shapes (thin/fat) are generated and used. No shape keys. For some combinations it is easier to show nothing at this LOD or show something closer/generic. Hair physics disabled (make sure no accumulated momentum and no motion because of physics manipulation).
- LOD3. distance >= 60m and < 80m, Like LOD2, but from combined meshes, decimated ones are generated with recommended triangles limit of 400 and hard limit of 400, more close/generic ones at this stage.
- Because of lower NPC counts in interior environments it is enough to have LOD0 and LOD1 in them, but it should be benchmarked and checked.
- character prefabs should have "character." prefix in file names to not mix with other prefabs. It should be supported with prefab save/load in editor to easily add prefix.
- 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.)
- **Schedule-adherent drivers** (replaces 5.4's placeholder cruising):
a driving NPC must follow its NpcScheduleSystem schedule, not loop
endlessly — when the schedule says move, the NPC walks to its parked
car (or spawns into it if low-detail), drives to the destination's
parking/curb, parks (handbrake, engine off), alights and spends the
scheduled dwell time there (inside the building once Phase 8
interiors work), then departs on the next schedule leg. The
walk-vs-drive distance choice deferred from 5.4 lands here: short
legs walk, long legs drive, and the choice must respect the time
budget the schedule leaves for the leg (a walking NPC that would be
late drives instead). Cruising with no schedule leg to serve becomes
rare ambient traffic at most, not the default.
- 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. A scripted driver completes a
scheduled trip (depart -> drive -> park -> alight -> dwell ->
schedule resumes on the next leg) and the F7 overlay shows the trip
state.
**Already in place (no work needed — do not redo):**
- Symbolic schedule-driven pedestrian motion, clock-pure and
reload-safe (NpcMotionSystem, 4.7.5); high-detail spawn/despawn tier
with hysteresis and indoor-eligibility rules (NpcDetailSystem,
4.7.6/4.9.1); sidewalk/shoulder walking via `offsetToShoulder`
(4.9.2); road clearing + context-aware local avoidance
(NpcAvoidanceSystem, 4.9.3-4.9.5); taxi ride choreography with
walk-to-curb/boarding/seat-pinning/alighting (NpcTaxiSystem, 4.7.7).
- AI driving: pure-pursuit + speed controller (5.1), whisker braking
(5.2), junction priority (5.3); the population car fleet with
deterministic ownership, parked placement and the two-tier
symbolic/high-detail split (NpcTrafficSystem, 5.4).
**6.A — Schedule-adherent drivers — DONE (code; replaces 5.4's endless
cruising; moved here from phase 8):**
1. Symbolic leg evaluation: per frame, derive the driver's schedule
legs (location changes) from the clock — same anchored-departure
model as NpcMotionSystem (`NpcMotionSystem::nextLocationChangeOf`
helper shared by both systems so they can never disagree). Per
leg decide walk vs drive: drive only when the car is parked at the
leg's origin location AND (walk distance > `maxWalkDistanceM` OR
the walk would not fit the schedule span — a walking NPC that would
be late drives instead). The car's parked location is derived from
the completed drive legs of the day (clock-pure, reload-safe — no
sticky parked state to save).
2. Leg routes: drive route from the current parking spot to the
destination parking (the driver's owned spot when going home, the
nearest Parking/Curb location otherwise), built with
`RoadPath::findPath` + `offsetToRightLane` + junction
classification; cached per parking pair, dropped on graph version
change. Drive departure anchored so the car ARRIVES when the next
entry starts (cruise speed + small parking overhead).
3. Suspension window: the driver is suspended from NpcMotionSystem
for [walk-departure, arrival] only (idempotent, clock-derived, so
scrubbing stays deterministic). Phases inside the window:
wait-at-location -> walk-to-car (symbolic lerp doorstep -> parked
car) -> driving (registry position advances along the route at
cruise speed). The waiting phase suppresses the foot walk from the
walk layer's own would-be departure, derived purely through
`NpcMotionSystem::walkTravelHours` (no latch).
4. High-detail spawn: parked car (handbrake, no driver) or on-route
car with a non-looping AiDriverComponent; driver seated via the
manual-spawner + seat-pinning pattern. **Clock-purity rule:
`rec->position` is ALWAYS clock-derived — the live tier never
writes it back** (not the live walker, not the live car, not the
alight drop point); live entities are visual approximations.
Observed physics is best-effort — the AI can park after the
symbolic clock already chained into the next leg (late board,
junction yield), so the alight check lives outside the Driving
phase and a late alight during a NEW symbolic leg despawns the car
to resync. Nothing projects back on despawn.
5. Boarding/alighting choreography: during walk-to-car the traffic
system aims the driver character's PathFollowingComponent at the
driver-door point (NpcTaxiSystem `driveTarget` pattern); seating on
reach (physics off, "driving" locomotion state). The non-looping
route parks the car by itself (arrived + handbrake); on arrival the
driver alights beside the car (physics on), unsuspends, and the
detail tier walks it from the car to the location doorstep.
NpcTaxiSystem refuses goals for driving AND seated drivers
(`isDriving`/`isSeated`).
6. Remove the endless cruising loop; `cruisingRatio` goes away
(ambient cruising may return later as a rare garnish, never the
default).
7. F7 overlay: show the driver's leg state (parked at X / waiting /
walking to car / driving to Y / arrived) — [CAR] marker + car
detail section.
8. Headless `--test-traffic-schedule`: scrub the clock to a driver's
work-leg morning — car parked at home before departure, driver
boards and the car drives when observed, symbolic tier advances
when not observed, car parked at the destination parking after
arrival with the driver back on its schedule (dwells the work
block), evening return leg drives home; scrubbing backwards
reproduces states bit-exactly (low-detail). Replaces
`--test-traffic-town` (cruising loop probe).
Update-order note (found 2026-10-24): NpcTrafficSystem::update runs
BETWEEN NpcPopulationSystem and NpcMotionSystem in
EditorApp::frameRenderingQueued — evalSymbolic (un)suspends drivers
and the motion layer must see the fresh suspension flag in the same
frame; with traffic after motion, the frame a drive leg ended left the
registry position at the last symbolic car position for one frame and
broke `--test-motion`'s bit-exact reproducibility.
**6.B — Pedestrian flow completion — DONE (small; the core was done):**
- Leaving/entering houses already starts/ends at the doorstep POI
(4.7.3/4.9.1). Real walk-through-interior exits arrive with Phase
8 — nothing to do here.
- Pedestrians waiting for their drive leg stand at the location
(doorstep, not mid sidewalk) via the clock-pure waiting suppression;
the walk-to-car leg is exempt from NpcAvoidanceSystem's fan and road
escape (the driver's own car is the goal, not an obstacle) until
boarding.
**6.C — Character asset system (asset pipeline +
assets/blender/characters; partly user modeling work):**
1. Detailed nude parts for the no-clothes case — DONE (code side).
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. Remaining: author the
detailed variants in the blends (modelling work — see
assets/blender/characters/CHARACTER_MODELING.md §3).
2. Character prefabs get a `character.` file-name prefix — DONE
(code side: `CharacterRegistry::generatePrefabPath` writes
`prefabs/character.<id>.json`, legacy `char_<id>.json` paths
migrate on save — deliberately NOT on load: scene-embedded spawners
and demo staging reference the old names), with prefab save/load
support in the editor for easy renaming.
Remaining: rename the existing prefab files (modeller/user side).
Modeller-facing pipeline notes:
assets/blender/characters/CHARACTER_MODELING.md.
3. Manual LOD scheme (Ogre mesh LODs):
- LOD0 (< 10 m): modular, highly detailed body; materials optimized
but not at the cost of quality.
- LOD1 (10-20 m): modular, one material per body part (same style
as the cars — one shared material), 2048x2048 texture atlas, face
replaced by a face mask from the same atlas, shape keys removed.
- LOD2 (20-60 m): pre-generated combinations of body parts and
shapes (thin/fat); no shape keys; hair physics disabled (no
accumulated momentum from physics manipulation); generic/closest
stand-ins allowed.
- LOD3 (60-80 m): decimated combined meshes, triangle budget ~400
(hard limit 400).
- Interiors only need LOD0/LOD1 (fewer NPCs) — benchmark to
confirm.
4. Automate LOD + atlas generation from the source meshes in the
export pipeline (like the vehicle ID-map bake); findings that
require serious pipeline changes are recorded for later phases.
5. Batch/FPS budgets from 5.5 must hold with a full walking
population (verify with --benchmark / --benchmark-drive).
**Verification:** `--test-traffic-schedule` (6.A), existing
`--test-motion` / `--test-detail` / `--test-taxi` /
`--test-determinism` keep passing; `--benchmark-drive` with full
population + traffic holds the 5.5 budgets.
### Phase 6.5 CellGrid furniture placement - before interiors
@@ -598,9 +598,9 @@ struct PopulationSwitchTestListener : public Ogre::FrameListener {
* Asserts: at least one NPC is IN_TRANSIT at some probe, at least one
* NPC ends the day at a different position than it started, and the
* anchored evaluation is deterministic (revisiting a probe hour
* reproduces the recorded positions exactly). Cruising traffic
* drivers (phase 5.4) are skipped: their position follows the car, not
* the clock-driven schedule.
* reproduces the recorded positions exactly). Traffic drivers in a
* drive-leg window (phase 5.4/6.A) are skipped: their position follows
* the car, not the walk layer.
*/
/* True while NpcTrafficSystem has the NPC suspended in its car (their
@@ -698,6 +698,7 @@ struct MotionTestListener : public Ogre::FrameListener {
return true;
/* Snapshot this probe. */
NpcMotionSystem::MotionInfo mi;
NpcTrafficSystem *tr = NpcTrafficSystem::getInstance();
for (uint64_t id : pop->populationCharacterIds()) {
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
@@ -714,6 +715,21 @@ struct MotionTestListener : public Ogre::FrameListener {
if (state == 1)
++transitSeen;
snapshots[probe][id] = { state, rec->position };
if (tr && tr->isDriving(id)) {
NpcTrafficSystem::TrafficInfo ti;
tr->trafficInfoOf(id, ti);
std::cout << std::fixed;
std::cout.precision(3);
std::cout << "[dbg] probe " << probe
<< " NPC " << id
<< " DRIVING phase "
<< ti.phase << " leg "
<< ti.legFrom << ">"
<< ti.legTo << " pos ("
<< rec->position.x << ", "
<< rec->position.z << ")"
<< std::endl;
}
}
/* Trace the first NPC at a few probe hours. */
if (probe == 0 || probe == 14 || probe == 18 ||
@@ -804,6 +820,32 @@ struct MotionTestListener : public Ogre::FrameListener {
continue;
if (rec->position.distance(
kv.second.second) > 0.001f) {
NpcTrafficSystem *tr =
NpcTrafficSystem::getInstance();
NpcTrafficSystem::TrafficInfo ti;
bool hasTi = tr &&
tr->trafficInfoOf(kv.first, ti);
std::cout << std::fixed;
std::cout.precision(3);
std::cout << "[dbg] NPC " << kv.first
<< " expected ("
<< kv.second.second.x << ", "
<< kv.second.second.y << ", "
<< kv.second.second.z
<< ") got ("
<< rec->position.x << ", "
<< rec->position.y << ", "
<< rec->position.z << ")"
<< " driving="
<< (tr &&
tr->isDriving(kv.first))
<< (hasTi ?
" phase=" +
std::to_string(ti.phase) +
" leg " + ti.legFrom +
">" + ti.legTo :
std::string())
<< std::endl;
failed = true;
failReason =
"position at 9.0 h not "
@@ -1358,8 +1400,38 @@ struct TaxiTestListener : public Ogre::FrameListener {
<< std::endl;
return true;
}
if (ti.state != NpcTaxiSystem::State::Riding)
if (ti.state != NpcTaxiSystem::State::Riding) {
if (frame % 600 == 0) {
flecs::entity ch =
reg.findSpawnedEntity(npc);
Ogre::Vector3 p = Ogre::Vector3::ZERO;
if (ch.is_alive() &&
ch.has<TransformComponent>() &&
ch.get<TransformComponent>().node)
p = ch.get<TransformComponent>()
.node
->_getDerivedPosition();
flecs::entity veh = app->getWorld()
->entity(vehicleId);
Ogre::Vector3 vp = Ogre::Vector3::ZERO;
if (veh.is_alive() &&
veh.has<TransformComponent>() &&
veh.get<TransformComponent>()
.node)
vp = veh.get<TransformComponent>()
.node
->_getDerivedPosition();
std::cout << "[dbg] taxi wait state "
<< (int)ti.state << " entity "
<< (ch.is_alive() ? 1 : 0)
<< " pos " << p.x << " " << p.y
<< " " << p.z << " veh "
<< vp.x << " " << vp.y << " "
<< vp.z << " frame "
<< frame << std::endl;
}
return true;
}
/* Riding: the seat must be occupied by the NPC's
* entity and its physics must be off. */
flecs::entity vehicle = app->getWorld()->entity(
@@ -4683,38 +4755,57 @@ struct TrafficYieldTestListener : public Ogre::FrameListener {
};
/*
* --test-traffic-town (phase 5.4): the population's car fleet on the
* --test-traffic-schedule (phase 6.A): schedule-adherent traffic on the
* generated town (exterior scene).
*
* Waits for the population + fleet seeding, picks the first cruising
* car, teleports the player next to its symbolic position and expects
* the high-detail tier: a sedan prefab instance with an
* AiDriverComponent and a driver character seated in the driver seat.
* The car must actually move; then the player teleports far away and
* the car must despawn while its SYMBOLIC progress keeps advancing;
* teleporting back respawns it near the symbolic position. PASS when
* the respawn lands within 40 m of the symbolic dot; FAIL on watchdog,
* a missing fleet, no spawn/despawn, a NaN transform, or a stalled
* symbolic tier.
* Part A (symbolic layer): with the pedestrian detail range untouched
* but the vehicle high-detail tier suppressed (maxActiveVehicles = 0)
* and maxWalkDistanceM forced low so drive legs exist, the paused clock
* is swept over the working day in float-exact 15-minute steps and every
* car's (phase, position) is recorded. Asserts at least one car DRIVES
* a leg at some probe (phase 2, position off its home spot) and that
* re-visiting three probe times reproduces every car position bit-
* exactly (clock-pure evaluation, surviving the backward scrub).
*
* Part B (observed tier): the shortest driving leg from the sweep is
* re-entered (same day, same hour — deterministic), the player
* teleports next to the car and the high-detail choreography must run:
* sedan spawns with an AiDriverComponent, a driver is seated
* (isSeated), the car physically moves, the AI parks (arrived), the
* driver alights (seat released, suspension dropped, phase back to
* parked) — the "drivers never leave the car" regression net. FAIL on
* watchdog, NaN, a missing fleet, no drive leg, no spawn, no seating,
* a stalled car, or a driver that never gets out.
*/
struct TrafficTownTestListener : public Ogre::FrameListener {
struct TrafficScheduleTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 0;
bool failed = false;
Ogre::String failReason;
int deadline = 30000;
uint64_t carId = 0;
int deadline = 40000;
int phaseFrame = 0;
int teleportFrames = 0;
Ogre::Vector3 teleportTarget = Ogre::Vector3::ZERO;
Ogre::Vector3 driveStartPos = Ogre::Vector3::ZERO;
float progressSample = 0.0f;
int populationAtSeed = 0;
TrafficTownTestListener(EditorApp *a)
static const uint32_t kDay = 5;
static const int kProbes = 64; /* 6:00 .. 22:00 in 15 min steps */
int probe = 0;
int settleFrames = 0;
std::vector<std::map<uint64_t, std::pair<int, Ogre::Vector3>>> snaps;
int driveProbes = 0;
/* Shortest driving leg seen (part B target). */
uint64_t driveCar = 0;
float driveHour = 0.0f;
float driveLegLen = FLT_MAX;
Ogre::Vector3 driveStartPos = Ogre::Vector3::ZERO;
bool moved = false;
TrafficScheduleTestListener(EditorApp *a)
: app(a)
{
snaps.resize(kProbes);
}
bool fail(const Ogre::String &reason)
@@ -4725,6 +4816,11 @@ struct TrafficTownTestListener : public Ogre::FrameListener {
return true;
}
float probeHour(int p) const
{
return 6.0f + (float)p * 0.25f;
}
/* Re-teleport for a few frames: streaming terrain colliders may
* not exist yet at the target, so the character can fall through
* on the first frames (same trick as DebugCaptureListener). */
@@ -4750,6 +4846,25 @@ struct TrafficTownTestListener : public Ogre::FrameListener {
t.position = teleportTarget;
}
void recordProbe(NpcTrafficSystem *traffic, float hour)
{
for (uint64_t id : traffic->carDriverIds()) {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(id, info))
continue;
Ogre::Vector3 pos;
if (!traffic->carWorldPosition(id, pos))
continue;
snaps[probe][id] = std::make_pair(info.phase, pos);
if (info.phase == 2 && info.routeLength > 1.0f &&
info.routeLength < driveLegLen) {
driveLegLen = info.routeLength;
driveCar = id;
driveHour = hour;
}
}
}
bool frameRenderingQueued(const Ogre::FrameEvent &evt) override
{
(void)evt;
@@ -4762,117 +4877,106 @@ struct TrafficTownTestListener : public Ogre::FrameListener {
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
if (!traffic || !pop)
GameClockSystem *clock = GameClockSystem::getInstance();
if (!traffic || !pop || !clock)
return true;
if (phase == 0) {
/* Wait for the fleet. */
/* Wait for the fleet, then pin the test
* parameters BEFORE the sweep. */
if (traffic->carCount() == 0)
return true;
for (uint64_t id : traffic->carDriverIds()) {
NpcTrafficSystem::TrafficInfo info;
if (traffic->trafficInfoOf(id, info) &&
info.cruising) {
carId = id;
break;
}
}
if (!carId)
return fail("fleet seeded but no cruising car");
populationAtSeed = pop->countPopulationNpcs();
std::cout << "[test] traffic town: fleet "
<< traffic->carCount() << " car(s), "
<< "population " << populationAtSeed
<< ", following driver " << carId
/* Force drive legs to exist and keep them short. */
traffic->maxWalkDistanceM = 50.0f;
traffic->cruiseSpeed = 22.0f;
/* No high-detail spawns during the sweep: part A is
* the pure symbolic layer. */
traffic->maxActiveVehicles = 0;
clock->setPaused(true);
std::cout << "[test] traffic schedule: fleet "
<< traffic->carCount() << " car(s), sweeping "
"the clock"
<< std::endl;
phase = 1;
return true;
}
if (phase == 1) {
/* Teleport next to the symbolic car, expect the
* high-detail tier. */
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(carId, carPos))
return fail("car vanished");
if (teleportFrames <= 0 && phaseFrame == 0)
startTeleport(carPos + Ogre::Vector3(20.0f,
3.0f,
20.0f));
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
traffic->trafficInfoOf(carId, info);
if (!info.highDetail) {
if (phaseFrame > 1200)
return fail("car never spawned high "
"detail");
/* Sweep: set the probe time, settle 3 frames (the
* clock-jump reset + the symbolic evaluation),
* record. */
if (settleFrames == 0)
clock->setTime(kDay, probeHour(probe));
if (++settleFrames < 3)
return true;
}
phaseFrame = 0;
phase = 2;
std::cout << "[test] traffic town: car spawned high "
"detail"
settleFrames = 0;
recordProbe(traffic, probeHour(probe));
if (++probe < kProbes)
return true;
/* Count probes with any driving car. */
for (int p = 0; p < kProbes; ++p)
for (const auto &kv : snaps[p])
if (kv.second.first == 2) {
++driveProbes;
break;
}
if (driveProbes == 0 || !driveCar)
return fail("no drive leg observed in a full "
"day sweep");
std::cout << "[test] traffic schedule: " << driveProbes
<< " probes with driving; shortest leg "
<< driveLegLen << " m (driver " << driveCar
<< ") at " << driveHour << " h"
<< std::endl;
probe = 0;
phase = 2;
return true;
}
if (phase == 2) {
/* The car must drive: sample the live position
* now and 15 sim-seconds later. */
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(carId, carPos))
return fail("car vanished while high detail");
if (!std::isfinite(carPos.x) ||
!std::isfinite(carPos.y) ||
!std::isfinite(carPos.z))
return fail("NaN car position");
if (phaseFrame == 0)
driveStartPos = carPos;
++phaseFrame;
if (phaseFrame < 900)
/* Determinism: re-visit probes 8/24/40, positions
* must reproduce bit-exactly. */
static const int revisit[3] = { 8, 24, 40 };
if (settleFrames == 0)
clock->setTime(kDay,
probeHour(revisit[probe]));
if (++settleFrames < 3)
return true;
float moved = (carPos - driveStartPos).length();
if (moved < 5.0f)
return fail("car did not move while high "
"detail");
std::cout << "[test] traffic town: car drove " << moved
<< " m in 15 s" << std::endl;
phaseFrame = 0;
settleFrames = 0;
for (const auto &kv : snaps[revisit[probe]]) {
Ogre::Vector3 pos;
if (!traffic->carWorldPosition(kv.first,
pos))
return fail("car vanished on re-probe");
if (pos != kv.second.second)
return fail("car position not "
"reproduced on re-probe");
}
if (++probe < 3)
return true;
std::cout << "[test] traffic schedule: re-probes "
"bit-exact"
<< std::endl;
phase = 3;
return true;
}
if (phase == 3) {
/* Far away: the car despawns, the symbolic tier
* keeps advancing. */
if (phaseFrame == 0) {
Ogre::Vector3 carPos;
traffic->carWorldPosition(carId, carPos);
startTeleport(carPos + Ogre::Vector3(600.0f,
30.0f,
600.0f));
}
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
traffic->trafficInfoOf(carId, info);
if (info.highDetail) {
if (phaseFrame > 1200)
return fail("car never despawned");
return true;
}
if (progressSample == 0.0f) {
progressSample = info.progress;
return true;
}
if (phaseFrame < 300)
return true;
if (info.progress <= progressSample + 1.0f &&
info.laps == 0)
return fail("symbolic progress stalled after "
"despawn");
std::cout << "[test] traffic town: despawned, symbolic "
"progress "
<< progressSample << " -> " << info.progress
/* Part B setup: jump back to the observed driving
* leg, restore the high-detail tier, run the clock
* at real time so the AI driver and the schedule
* advance together. */
traffic->maxActiveVehicles = 8;
clock->setTime(kDay, driveHour);
clock->setSpeed(1.0f);
clock->setPaused(false);
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(driveCar, carPos))
return fail("chosen car vanished");
startTeleport(carPos +
Ogre::Vector3(20.0f, 3.0f, 20.0f));
std::cout << "[test] traffic schedule: observing "
"driver " << driveCar
<< std::endl;
phaseFrame = 0;
phase = 4;
@@ -4880,36 +4984,71 @@ struct TrafficTownTestListener : public Ogre::FrameListener {
}
if (phase == 4) {
/* Back near: the car respawns close to its
* symbolic position. */
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(carId, carPos))
return fail("car vanished");
if (phaseFrame == 0)
startTeleport(carPos + Ogre::Vector3(20.0f,
3.0f,
20.0f));
/* The car spawns high-detail with a seated
* driver. */
if (teleportFrames > 0)
return true;
++phaseFrame;
NpcTrafficSystem::TrafficInfo info;
traffic->trafficInfoOf(carId, info);
if (!info.highDetail) {
if (phaseFrame > 1200)
return fail("car never respawned");
if (!traffic->trafficInfoOf(driveCar, info))
return fail("chosen car record lost");
if (!info.highDetail || !traffic->isSeated(driveCar)) {
if (phaseFrame > 2400)
return fail("car never spawned with a "
"seated driver");
return true;
}
/* The live position (carWorldPosition prefers the
* entity while high detail) must sit near the
* route polyline — respawn happened at the
* symbolic progress. */
if (pop->countPopulationNpcs() != populationAtSeed)
return fail("population changed during the "
"test");
std::cout << "[test] PASS: traffic town (despawn/"
"respawn stable, progress "
<< info.progress << " of "
<< info.routeLength << ")" << std::endl;
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(driveCar, carPos))
return fail("car vanished while high detail");
if (!std::isfinite(carPos.x) ||
!std::isfinite(carPos.y) ||
!std::isfinite(carPos.z))
return fail("NaN car position");
driveStartPos = carPos;
std::cout << "[test] traffic schedule: high detail, "
"driver seated"
<< std::endl;
phaseFrame = 0;
phase = 5;
app->getRoot()->queueEndRendering();
return true;
}
if (phase == 5) {
/* The car must physically drive. */
++phaseFrame;
Ogre::Vector3 carPos;
if (!traffic->carWorldPosition(driveCar, carPos))
return fail("car vanished while driving");
if (!moved) {
if ((carPos - driveStartPos).length() >= 5.0f) {
moved = true;
std::cout << "[test] traffic schedule: "
"car driving"
<< std::endl;
} else if (phaseFrame > 2400) {
return fail("car never moved");
}
}
/* Arrival: the AI parks, the driver alights, the
* suspension drops and the schedule owns the
* driver again. */
NpcTrafficSystem::TrafficInfo info;
traffic->trafficInfoOf(driveCar, info);
if (moved && info.phase == 0 &&
!traffic->isDriving(driveCar) &&
!traffic->isSeated(driveCar)) {
std::cout << "[test] PASS: traffic schedule "
"(drove, parked, driver alighted, "
"schedule resumed)"
<< std::endl;
phase = 6;
app->getRoot()->queueEndRendering();
return true;
}
if (phaseFrame > 20000)
return fail("car never arrived / driver never "
"alighted");
return true;
}
return true;
@@ -6500,7 +6639,7 @@ int main(int argc, char *argv[])
bool testTrafficLoop = false;
bool testTrafficFollow = false;
bool testTrafficYield = false;
bool testTrafficTown = false;
bool testTrafficSchedule = false;
bool benchmark = false;
bool benchmarkDrive = false;
float driveSpeed = 13.0f;
@@ -6563,8 +6702,8 @@ int main(int argc, char *argv[])
testTrafficFollow = true;
} else if (arg == "--test-traffic-yield") {
testTrafficYield = true;
} else if (arg == "--test-traffic-town") {
testTrafficTown = true;
} else if (arg == "--test-traffic-schedule") {
testTrafficSchedule = true;
} else if (arg == "--benchmark") {
benchmark = true;
} else if (arg == "--benchmark-drive") {
@@ -6682,8 +6821,8 @@ int main(int argc, char *argv[])
sceneFile = "demo_scene_vehicletest.json";
if (testTrafficYield && !sceneArgGiven)
sceneFile = "demo_scene_vehicletest.json";
/* The town traffic test needs the generated town. */
if (testTrafficTown && !sceneArgGiven)
/* The schedule traffic test needs the generated town. */
if (testTrafficSchedule && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The benchmark measures the generated town. */
if (benchmark && !sceneArgGiven)
@@ -6824,9 +6963,9 @@ int main(int argc, char *argv[])
if (testTrafficYield)
app.getRoot()->addFrameListener(&trafficYieldListener);
TrafficTownTestListener trafficTownListener(&app);
if (testTrafficTown)
app.getRoot()->addFrameListener(&trafficTownListener);
TrafficScheduleTestListener trafficScheduleListener(&app);
if (testTrafficSchedule)
app.getRoot()->addFrameListener(&trafficScheduleListener);
BenchmarkListener benchmarkListener(&app);
if (benchmark) {
@@ -7186,19 +7325,19 @@ int main(int argc, char *argv[])
}
}
if (testTrafficTown) {
if (trafficTownListener.failed) {
if (testTrafficSchedule) {
if (trafficScheduleListener.failed) {
std::cerr << "[test] FAIL: "
<< trafficTownListener.failReason
<< trafficScheduleListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (trafficTownListener.phase != 5) {
if (trafficScheduleListener.phase != 6) {
std::cerr << "[test] FAIL: incomplete (traffic "
"town phase "
<< trafficTownListener.phase << ")"
"schedule phase "
<< trafficScheduleListener.phase << ")"
<< std::endl;
app.clearScene();
app.closeApp();
@@ -67,7 +67,32 @@ std::string CharacterRegistry::generatePrefabPath(uint64_t id,
const std::string &,
const std::string &) const
{
return "prefabs/char_" + std::to_string(id) + ".json";
/* Phase 6.C.2: character prefabs carry the "character." prefix
* (legacy "char_<id>.json" files migrate on load/save). */
return "prefabs/character." + std::to_string(id) + ".json";
}
/* Rename a legacy "prefabs/char_<id>.json" path to the modern
* "prefabs/character.<id>.json" form, moving the file when it exists.
* No-op for paths that already match neither pattern (custom paths). */
void CharacterRegistry::migratePrefabPath(CharacterRecord &c)
{
const std::string legacy =
"prefabs/char_" + std::to_string(c.id) + ".json";
if (c.prefabPath != legacy)
return;
const std::string modern =
generatePrefabPath(c.id, c.firstName, c.lastName);
if (std::filesystem::exists(c.prefabPath) &&
!std::filesystem::exists(modern)) {
std::error_code ec;
std::filesystem::rename(c.prefabPath, modern, ec);
if (ec)
return; /* keep the working legacy path */
}
if (std::filesystem::exists(modern) ||
!std::filesystem::exists(c.prefabPath))
c.prefabPath = modern;
}
/* ===================================================================== */
@@ -825,10 +850,13 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
bool CharacterRegistry::savePrefabForCharacter(uint64_t id)
{
const CharacterRecord *c = findCharacter(id);
CharacterRecord *c = findCharacter(id);
if (!c)
return false;
/* Migrate legacy "char_<id>.json" paths forward on save. */
migratePrefabPath(*c);
try {
nlohmann::json j;
{
@@ -462,6 +462,13 @@ private:
const std::string &firstName,
const std::string &lastName) const;
/* Rename a legacy "prefabs/char_<id>.json" record path to the
* modern "prefabs/character.<id>.json" form, moving the file when
* it exists. Called from savePrefabForCharacter() only — load is
* deliberately migration-free because scene-embedded spawners and
* demo staging lists still reference the legacy path. */
void migratePrefabPath(CharacterRecord &c);
/* UI state */
int m_editorTab = 0;
uint64_t m_selectedCharacterId = 0;
@@ -386,6 +386,31 @@ 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;
}
}
if (detailed)
return entry["mesh"].get<Ogre::String>();
}
}
/* Fallback to layer 0 (nude base) — prefer shortest name.
* Base mesh names are shortest; combined meshes add suffixes.
* We also penalise Blender duplicate names (.001, .002). */
@@ -13,6 +13,7 @@
#include "NpcMotionSystem.hpp"
#include "NpcPopulationSystem.hpp"
#include "NpcTaxiSystem.hpp"
#include "NpcTrafficSystem.hpp"
#include "PhysicsSystem.hpp"
#include "RenderOriginSystem.hpp"
#include "RoadSystem.hpp"
@@ -46,6 +47,7 @@ void NpcAvoidanceSystem::update()
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
NpcDetailSystem *detail = NpcDetailSystem::getInstance();
NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance();
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
if (!pop || !detail)
return;
CharacterRegistry &reg = CharacterRegistry::getSingleton();
@@ -140,8 +142,15 @@ void NpcAvoidanceSystem::update()
/* Taxi-owned NPCs walk to the car door ON the carriageway
* on purpose: no road escape and no obstacle whisker (the
* whisker would halt them off the very car they are
* boarding). NPC-NPC separation still applies. */
const bool taxiOwned = taxi && taxi->hasActiveGoal(w.id);
* boarding). NPC-NPC separation still applies. A traffic
* driver walking to their OWN car (phase 6.A walk-to-car)
* has the same boarding need. */
bool taxiOwned = taxi && taxi->hasActiveGoal(w.id);
if (!taxiOwned && traffic && traffic->isDriving(w.id)) {
NpcTrafficSystem::TrafficInfo ti;
if (traffic->trafficInfoOf(w.id, ti) && ti.phase == 1)
taxiOwned = true;
}
/* Road clearing (phase 4.9.4): an NPC on the carriageway
* and not at its goal runs off it, perpendicular to the
@@ -62,12 +62,22 @@ bool NpcDetailSystem::isSpawnEligible(uint64_t registryId) const
NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance();
if (taxi && taxi->hasActiveGoal(registryId))
return true;
/* Traffic drivers (phase 5.4) sit in their car — suspended from
* the schedule layer, so motionInfoOf reports nothing and the
* fallthrough below would wrongly spawn them as pedestrians. */
/* Traffic drivers (phase 5.4/6.A) sit in their car — suspended
* from the schedule layer, so motionInfoOf reports nothing and
* the fallthrough below would wrongly spawn them as pedestrians.
* Exception: in the walk-to-car phase the driver IS a pedestrian
* (NpcDetailSystem walks them to the driver door via
* NpcTrafficSystem::driveTarget, the NpcTaxiSystem boarding
* pattern); once seated they must not spawn/despawn as a
* pedestrian again. */
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
if (traffic && traffic->isDriving(registryId))
if (traffic && traffic->isDriving(registryId)) {
NpcTrafficSystem::TrafficInfo ti;
if (traffic->trafficInfoOf(registryId, ti) &&
ti.phase == 1 && !traffic->isSeated(registryId))
return true;
return false;
}
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
if (!motion || !pop)
@@ -313,6 +323,44 @@ void NpcDetailSystem::driveHighDetail(uint64_t registryId,
auto &pf = character.get_mut<PathFollowingComponent>();
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
/* Phase 6.A: a traffic driver in the walk-to-car phase is driven
* by NpcTrafficSystem (suspended from NpcMotionSystem, so
* motionInfoOf would report nothing) — walk to the driver door
* of their own car, the NpcTaxiSystem boarding pattern. */
NpcTrafficSystem *trafficSys = NpcTrafficSystem::getInstance();
if (trafficSys && trafficSys->isDriving(registryId)) {
Ogre::Vector3 wantWorld;
if (!trafficSys->driveTarget(registryId, wantWorld) ||
trafficSys->isSeated(registryId)) {
/* Waiting for board time (or already seated):
* stand. */
if (pf.hasTarget) {
pf.hasTarget = false;
pf.path.clear();
pf.pathIndex = 0;
}
return;
}
Ogre::Vector3 wantRender = wantWorld;
if (ro)
wantRender = ro->worldToRender(wantWorld.x,
wantWorld.y,
wantWorld.z);
if (!pf.hasTarget ||
(pf.targetPosition - wantRender).squaredLength() >
0.25f) {
pf.targetPosition = wantRender;
pf.hasTarget = true;
/* A few meters across the driveway: no road
* polyline, straight line plus the avoidance
* tier. */
pf.path.clear();
pf.pathIndex = 0;
pf.pathRecalcTimer = 0.0f;
}
return;
}
/* Phase 4.7.7: an NPC with an active taxi goal is driven by
* NpcTaxiSystem instead of the schedule layer (it is suspended
* from NpcMotionSystem, so motionInfoOf would report nothing). */
@@ -525,10 +573,16 @@ void NpcDetailSystem::update(float dt)
} else {
/* A riding NPC (phase 4.7.7) is pinned to the
* vehicle seat by NpcTaxiSystem: never despawn it
* out from under the ride. */
* out from under the ride. Same for a traffic
* driver seated in their own car (phase 6.A). */
NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance();
NpcTrafficSystem *traffic =
NpcTrafficSystem::getInstance();
bool riding = taxi && taxi->isRiding(id);
if (!riding && det.dwell >= minDwellSeconds &&
bool seatedInCar =
traffic && traffic->isSeated(id);
if (!riding && !seatedInCar &&
det.dwell >= minDwellSeconds &&
(distSq > despawnDistanceSq || arrivedIndoors)) {
spawnerSys->despawn(det.spawner);
det.highDetail = false;
@@ -91,15 +91,41 @@ bool NpcMotionSystem::isSuspended(uint64_t registryId) const
return m_suspended.count(registryId) != 0;
}
void NpcMotionSystem::update()
bool NpcMotionSystem::nextLocationChangeOf(
const TownSchedule::NpcSchedule &sched,
const TownLocations::LocationSet &locs,
float tod, double absHours, LocationChange &out)
{
if (!editScene::isGameMode())
return;
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
GameClockSystem *clock = GameClockSystem::getInstance();
if (!pop || !clock)
return;
out = LocationChange();
if (sched.entries.empty())
return false;
const TownSchedule::ScheduleEntry *active =
TownSchedule::entryAt(sched, tod);
if (!active)
return false;
out.active = active;
out.from = locs.findByName(active->location);
/* Next entry with a DIFFERENT location; entries after the active
* one start today, wrapped ones tomorrow. */
const int n = (int)sched.entries.size();
const int ai = (int)(active - &sched.entries[0]);
for (int k = 1; k < n; ++k) {
int j = (ai + k) % n;
const TownSchedule::ScheduleEntry &e = sched.entries[j];
if (e.location == active->location)
continue;
out.next = &e;
out.nextStartAbs = absHours - (double)tod +
(double)e.startHour + (j < ai ? 24.0 : 0.0);
out.to = locs.findByName(e.location);
break;
}
return true;
}
const RoadGraph *NpcMotionSystem::roadGraph()
{
/* The road graph for the coarse path layer; optional — without
* it NPCs walk straight lines. */
const RoadGraph *graph = nullptr;
@@ -114,6 +140,68 @@ void NpcMotionSystem::update()
m_pathCache.clear();
m_graphVersion = graph->version;
}
return graph;
}
const NpcMotionSystem::PathEntry *NpcMotionSystem::pathBetween(
int areaIdx, const std::string &fromLoc, const std::string &toLoc,
const Ogre::Vector3 &fromPos, const Ogre::Vector3 &toPos,
const RoadGraph *graph)
{
std::string cacheKey =
std::to_string(areaIdx) + "|" + fromLoc + ">" + toLoc;
auto cached = m_pathCache.find(cacheKey);
if (cached != m_pathCache.end())
return &cached->second;
std::vector<Ogre::Vector3> p;
bool found = false;
if (graph)
found = RoadPath::findPath(*graph, fromPos, toPos, p);
if (found)
/* Pedestrians walk the shoulder/sidewalk, not the
* traffic lanes (phase 4.9.2). */
RoadPath::offsetToShoulder(*graph, p);
if (!found) {
/* Disconnected graph or no graph: straight line. */
p.push_back(fromPos);
p.push_back(toPos);
}
float pathLength = RoadPath::polylineLength(p);
if (m_pathCache.size() >= kPathCacheCap)
m_pathCache.clear();
auto ins = m_pathCache.emplace(cacheKey,
std::make_pair(p, pathLength));
return &ins.first->second;
}
bool NpcMotionSystem::walkTravelHours(int areaIdx,
const TownLocations::LocationSet &locs,
const std::string &fromLoc,
const std::string &toLoc,
double &outHours)
{
const TownLocations::NpcLocation *from = locs.findByName(fromLoc);
const TownLocations::NpcLocation *to = locs.findByName(toLoc);
if (!from || !to)
return false;
const PathEntry *pe = pathBetween(areaIdx, fromLoc, toLoc,
from->position, to->position,
roadGraph());
outHours = (double)pe->second / ((double)walkSpeedMps * 3600.0);
return true;
}
void NpcMotionSystem::update()
{
if (!editScene::isGameMode())
return;
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
GameClockSystem *clock = GameClockSystem::getInstance();
if (!pop || !clock)
return;
const RoadGraph *graph = roadGraph();
const double tabs = clock->getAbsoluteHours();
const float tod = clock->getTimeOfDayHours();
@@ -137,13 +225,6 @@ void NpcMotionSystem::update()
continue;
}
const TownSchedule::ScheduleEntry *active =
TownSchedule::entryAt(*sched, tod);
if (!active) {
m_states.erase(id);
continue;
}
/* The evaluation is a pure function of the absolute clock:
* the NPC is at the active entry's location, except during
* the transit window before the next location-change entry
@@ -152,28 +233,15 @@ void NpcMotionSystem::update()
* clock backwards replays identically, and starting the
* game mid-day places everyone where the schedule says. */
NpcMotion &st = m_states[id];
const TownLocations::NpcLocation *cur =
locs->findByName(active->location);
/* Next entry with a DIFFERENT location; entries after the
* active one start today, wrapped ones tomorrow. */
const int n = (int)sched->entries.size();
const int ai = (int)(active - &sched->entries[0]);
const TownSchedule::ScheduleEntry *next = nullptr;
double nextStartAbs = 0.0;
for (int k = 1; k < n; ++k) {
int j = (ai + k) % n;
const TownSchedule::ScheduleEntry &e =
sched->entries[j];
if (e.location == active->location)
continue;
next = &e;
nextStartAbs = tabs - (double)tod +
(double)e.startHour + (j < ai ? 24.0 : 0.0);
break;
LocationChange lc;
if (!nextLocationChangeOf(*sched, *locs, tod, tabs, lc)) {
m_states.erase(id);
continue;
}
const TownSchedule::ScheduleEntry *active = lc.active;
const TownLocations::NpcLocation *cur = lc.from;
if (!next) {
if (!lc.next) {
/* Single-location schedule: never moves. */
if (cur)
rec->position = cur->position;
@@ -185,8 +253,9 @@ void NpcMotionSystem::update()
continue;
}
const TownLocations::NpcLocation *to =
locs->findByName(next->location);
const TownSchedule::ScheduleEntry *next = lc.next;
const double nextStartAbs = lc.nextStartAbs;
const TownLocations::NpcLocation *to = lc.to;
if (!cur || !to) {
/* A location vanished (town regenerate): report
* symbolically, leave the position alone. */
@@ -199,37 +268,11 @@ void NpcMotionSystem::update()
}
/* Coarse path, shared cache per (area, from, to). */
std::string cacheKey = std::to_string(areaIdx) + "|" +
active->location + ">" + next->location;
const std::vector<Ogre::Vector3> *path = nullptr;
float pathLength = 0.0f;
auto cached = m_pathCache.find(cacheKey);
if (cached != m_pathCache.end()) {
path = &cached->second.first;
pathLength = cached->second.second;
} else {
std::vector<Ogre::Vector3> p;
bool found = false;
if (graph)
found = RoadPath::findPath(*graph, cur->position,
to->position, p);
if (found)
/* Pedestrians walk the shoulder/sidewalk,
* not the traffic lanes (phase 4.9.2). */
RoadPath::offsetToShoulder(*graph, p);
if (!found) {
/* Disconnected graph or no graph: straight
* line. */
p.push_back(cur->position);
p.push_back(to->position);
}
pathLength = RoadPath::polylineLength(p);
if (m_pathCache.size() >= kPathCacheCap)
m_pathCache.clear();
auto ins = m_pathCache.emplace(
cacheKey, std::make_pair(p, pathLength));
path = &ins.first->second.first;
}
const PathEntry *pe =
pathBetween(areaIdx, active->location, next->location,
cur->position, to->position, graph);
const std::vector<Ogre::Vector3> *path = &pe->first;
float pathLength = pe->second;
/* Depart early enough to arrive at the entry start. If
* the trip is longer than the span the NPC has at the
@@ -8,6 +8,11 @@
#include <OgreVector3.h>
#include <flecs.h>
#include "../roadlib/TownLocations.hpp"
#include "../roadlib/TownSchedule.hpp"
struct RoadGraph;
/**
* NPC motion core — low-detail symbolic movement (demo-taxi phase
* 4.7.5).
@@ -66,6 +71,32 @@ public:
float pathLengthMeters = 0.0f;
};
/** Result of the shared schedule lookahead (phase 6.A.1). */
struct LocationChange {
/** Active entry at the queried time of day. */
const TownSchedule::ScheduleEntry *active = nullptr;
/** Next entry with a DIFFERENT location (nullptr when the
* schedule never moves). */
const TownSchedule::ScheduleEntry *next = nullptr;
/** Absolute hours when `next` starts. */
double nextStartAbs = 0.0;
/** Resolved location records (nullptr when the name is not
* in the location set). */
const TownLocations::NpcLocation *from = nullptr;
const TownLocations::NpcLocation *to = nullptr;
};
/** Shared schedule lookahead: the active entry, the next entry
* with a different location, when that entry starts in absolute
* hours and the resolved from/to locations. update() and
* NpcTrafficSystem both use it so the walk layer and the drive
* layer evaluate the same legs (demo-taxi phase 6.A.1). False
* when the schedule has no active entry at `tod`. */
static bool nextLocationChangeOf(const TownSchedule::NpcSchedule &sched,
const TownLocations::LocationSet &locs,
float tod, double absHours,
LocationChange &out);
/** Per-frame update; game mode only. */
void update();
@@ -81,6 +112,18 @@ public:
bool remainingPath(uint64_t registryId,
std::vector<Ogre::Vector3> &out) const;
/** Pedestrian travel time in hours for a leg, from the same
* shared path cache (with the shoulder offset) update() walks.
* False when a location name is not in the set. NpcTrafficSystem
* uses this to derive the walk layer's would-be departure time
* for a drive leg purely from the clock — no latch, no motion
* state peeking (demo-taxi phase 6.A). */
bool walkTravelHours(int areaIdx,
const TownLocations::LocationSet &locs,
const std::string &fromLoc,
const std::string &toLoc,
double &outHours);
/** Exclude/include an NPC from the schedule-driven evaluation
* (demo-taxi phase 4.7.7: NpcTaxiSystem drives the position of
* NPCs with an active taxi goal itself — a taxi ride waits on the
@@ -111,10 +154,22 @@ private:
/* Shared coarse-path cache: (area | from > to) -> (polyline,
* length), flushed whole on graph version change or overflow. */
using PathEntry = std::pair<std::vector<Ogre::Vector3>, float>;
uint64_t m_graphVersion = 0;
std::map<std::string, std::pair<std::vector<Ogre::Vector3>, float>>
m_pathCache;
std::map<std::string, PathEntry> m_pathCache;
static const size_t kPathCacheCap = 256;
/* Active terrain's road graph (nullptr when none); flushes the
* path cache on a version change. */
const RoadGraph *roadGraph();
/* Cache get-or-compute for one (area, from > to) pedestrian
* path (shoulder-offset when a graph exists, straight line
* otherwise). */
const PathEntry *pathBetween(int areaIdx, const std::string &fromLoc,
const std::string &toLoc,
const Ogre::Vector3 &fromPos,
const Ogre::Vector3 &toPos,
const RoadGraph *graph);
static NpcMotionSystem *ms_instance;
};
@@ -14,6 +14,7 @@
#include "NpcMotionSystem.hpp"
#include "NpcPopulationSystem.hpp"
#include "NpcTaxiSystem.hpp"
#include "NpcTrafficSystem.hpp"
NpcScheduleDebugOverlay::NpcScheduleDebugOverlay(EditorApp *editorApp)
: m_editorApp(editorApp)
@@ -160,6 +161,11 @@ void NpcScheduleDebugOverlay::render()
label += "]";
}
}
/* Phase 6.A traffic driver marker. */
NpcTrafficSystem *traffic =
NpcTrafficSystem::getInstance();
if (traffic && traffic->isDriving(id))
label += " [CAR]";
if (ImGui::Selectable(label.c_str(),
m_selected == flatIndex))
m_selected = flatIndex;
@@ -264,6 +270,47 @@ void NpcScheduleDebugOverlay::render()
}
}
}
/* Phase 6.A: the NPC's own car (traffic fleet) and its
* schedule-adherent leg state. */
NpcTrafficSystem *traffic =
NpcTrafficSystem::getInstance();
if (traffic) {
NpcTrafficSystem::TrafficInfo tri;
if (traffic->trafficInfoOf(selId, tri)) {
ImGui::Separator();
const char *phaseName =
tri.phase == 1 ?
"walking to car" :
tri.phase == 2 ?
"driving" :
"parked";
ImGui::Text("car: %s%s", phaseName,
tri.highDetail ?
" [HIGH]" :
"");
if (tri.phase != 0) {
ImGui::Text(" leg: %s -> %s",
tri.legFrom.c_str(),
tri.legTo.c_str());
if (tri.phase == 2 &&
tri.routeLength > 0.0f)
ImGui::Text(
" drive: %.0f / %.0f m",
tri.progress,
tri.routeLength);
if (clock) {
double etaMin =
(tri.arrivalAbs -
clock->
getAbsoluteHours()) *
60.0;
ImGui::Text(
" arrival in %.1f min",
etaMin);
}
}
}
}
/* Phase 4.7.8: the current path (taxi goal path
* first, then the schedule transit path). */
{
@@ -132,9 +132,13 @@ bool NpcTaxiSystem::assignTaxiGoal(uint64_t registryId,
if (!pop || !motion || !rec)
return false;
/* Traffic drivers (phase 5.4) are suspended in their car; a taxi
* goal would fight NpcTrafficSystem over the NPC. */
* goal would fight NpcTrafficSystem over the NPC. isSeated covers
* the late-arrival window where the symbolic leg already completed
* (suspension lifted) but the driver is still pinned in the live
* car. */
NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance();
if (traffic && traffic->isDriving(registryId))
if (traffic && (traffic->isDriving(registryId) ||
traffic->isSeated(registryId)))
return false;
int areaIdx = pop->areaIndexOf(registryId);
if (areaIdx < 0 || !findLocation(areaIdx, destLocation))
@@ -400,6 +404,30 @@ void NpcTaxiSystem::stepWaitAtCurb(uint64_t registryId, Goal &goal)
if (!best.is_alive())
return;
/* Board the CURB-SIDE door: among the free passenger seats pick
* the one whose door point is nearest the waiting NPC. Reserving
* a far-side seat makes the straight boarding walk pass through
* the car body — the NPC pushes the handbraked car (its capsule
* shoves it through the contact solver), the car slides away at
* the same rate, the gap never closes and the 4 m displacement
* abort loops forever (the pre-6.A --test-taxi stall). */
{
const VehicleComponent &vc = best.get<VehicleComponent>();
float bestDoorDist = FLT_MAX;
for (int i = 0; i < (int)vc.seats.size(); ++i) {
if (vc.seats[i].driver || vc.seats[i].occupant != 0)
continue;
Ogre::Vector3 door = doorPoint(best, i);
Ogre::Vector3 dd = door - rec->position;
dd.y = 0.0f;
float dl = dd.squaredLength();
if (dl < bestDoorDist) {
bestDoorDist = dl;
bestSeat = i;
}
}
}
/* Reserve the seat against other passengers. */
best.get_mut<VehicleComponent>().seats[bestSeat].occupant =
character.id();
File diff suppressed because it is too large Load Diff
@@ -16,46 +16,77 @@
class EditorApp;
struct RoadGraph;
namespace TownLocations {
struct NpcLocation;
struct LocationSet;
}
/**
* NPC vehicle traffic (demo-taxi phase 5.4).
* NPC vehicle traffic (demo-taxi phase 5.4, schedule-adherent since
* phase 6.A).
*
* Owns the population's car fleet: when a town area's population and
* location set exist (NpcPopulationSystem), a deterministic subset of
* the area's Parking locations gets a car (hash of the parking name vs
* carOwnershipRatio, same per-lot hashing discipline as TownLocations —
* editing one lot never shifts another lot's fleet; areas without
* parking rects fall back to the Curb locations as on-street parking). Each car belongs
* to a driver: the first adult population NPC whose home address
* matches the parking spot's owning building (fallback: the area's
* first adult).
* parking rects fall back to the Curb locations as on-street parking).
* Each car belongs to a driver: the first adult population NPC whose
* home address matches the parking spot's owning building (fallback:
* the area's first adult).
*
* A deterministic subset of the fleet (cruisingRatio) drives an endless
* loop — home parking -> a deterministic other Parking/Curb location ->
* back — built from RoadPath::findPath + offsetToRightLane and
* junction-classified against the road graph. The rest of the fleet
* stays parked at its Parking location (handbrake on, no driver).
* Cruising drivers are suspended from the schedule layer
* (NpcMotionSystem::setSuspended) and their registry position follows
* the car, like NpcTaxiSystem's Riding state; the walk-vs-drive choice
* is deferred to phase 8, so a cruising driver effectively lives in its
* car for now.
* Phase 6.A: cars follow their driver's schedule instead of cruising
* an endless loop. Per frame, per car, the driver's schedule is
* scanned from day start (a PURE function of the absolute clock, same
* discipline as NpcMotionSystem): a location-change leg is DRIVEN when
* the car is parked at the leg origin (the car chains through the day
* with its driver, home at day start) AND the walk would be
* unreasonable (straight-line distance over maxWalkDistanceM, or the
* walk would take longer than the span at the origin). A drive leg's
* timeline is anchored on ARRIVING when the next entry starts: the
* driver walks to the parked car (board lead = doorstep->car walk),
* then the car drives the right-lane route at cruiseSpeed plus a small
* parking overhead. Destination parking is the driver's own spot when
* going home, else the nearest Parking/Curb location to the
* destination. Leg routes are cached per location pair and dropped on
* a road graph version change.
*
* The driver is suspended from NpcMotionSystem for the whole leg
* window (including the walk to the car — NpcDetailSystem keeps them
* spawnable in that phase and aims their PathFollowingComponent at the
* driver door via driveTarget(), the same contract as NpcTaxiSystem's
* pickup boarding) and their registry position follows the walk/the
* car. A foot-walk for a leg that will be driven is suppressed as
* soon as the motion layer starts it: the driver waits at the origin
* until the computed board time.
*
* Two tiers, symbolic ground truth (same design as NpcDetailSystem):
* the TrafficCar record (route + progress in meters) is authoritative
* and survives spawn/despawn and scene switches; a high-detail vehicle
* entity (PrefabSystem::createInstance of vehiclePrefab, an
* AiDriverComponent for cruisers, and a driver character seated through
* the same manual-spawner + seat-pinning approach as NpcTaxiSystem)
* exists only while the camera is within spawnDistanceSq. Tier changes
* use hysteresis (despawn > spawn), a per-car dwell timer, a per-frame
* spawn budget (prefab instantiation + vehicle constraint setup is a
* frame hitch) and an active-vehicle cap.
* the clock-derived record is authoritative and survives spawn/despawn
* and scene switches; a high-detail vehicle entity (PrefabSystem
* instance of vehiclePrefab, a non-looping AiDriverComponent while
* under way, and a driver character seated through the same
* seat-pinning approach as NpcTaxiSystem) exists only while the camera
* is within spawnDistanceSq. While observed the live physics is the
* approximation and the schedule is best-effort: a late boarder delays
* the departure, junction yields delay the arrival — the suspension
* then holds past the clock arrival until the AI driver parks
* (AiDriverComponent::arrived), the driver alights beside the driver
* door (node moved BEFORE CharacterSystem::enablePhysics, same
* contract as VehicleControllerSystem::exitVehicle) and the schedule
* resumes. Tier changes use hysteresis (despawn > spawn), a per-car
* dwell timer, a per-frame spawn budget and an active-vehicle cap.
*
* NpcDetailSystem consults isDriving() so a suspended cruising driver
* never materializes as a pedestrian. A vehicle carrying the player
* (PlayerControlledComponent — carjacking) leaves the fleet: the record
* is erased, the driver alights in place and returns to its schedule.
* NpcDetailSystem consults isDriving()/isSeated() so a suspended
* driver never materializes as a pedestrian and a seated driver is
* never despawned from under the car; NpcTaxiSystem refuses goals for
* driving NPCs. A vehicle carrying the player
* (PlayerControlledComponent — carjacking) leaves the fleet: the
* record is erased, the driver alights in place and returns to its
* schedule. Symbolic cars are invisible and cannot collide — junction
* priority only exists in the high-detail tier, so a transient overlap
* at the tier boundary is accepted. Scrubbing the clock backwards (or
* jumping it forward) despawns all high-detail cars so the live state
* re-derives from the clock.
*/
class NpcTrafficSystem
{
@@ -69,18 +100,36 @@ public:
return ms_instance;
}
/** Read-out for debug UIs and the headless town traffic test. */
/** Read-out for debug UIs and headless tests. */
struct TrafficInfo {
bool cruising = false;
/** 0 parked, 1 walking to the car, 2 driving. */
int phase = 0;
bool highDetail = false;
/** Meters along the active leg's drive route. */
float progress = 0.0f;
float routeLength = 0.0f;
int laps = 0;
/** Schedule location names of the active leg ("" when
* parked). */
std::string legFrom;
std::string legTo;
/** Absolute hours when the leg must arrive (schedule
* start of the destination entry). */
double arrivalAbs = 0.0;
};
/** True while the NPC drives one of the fleet's cars (suspended
* from the schedule layer; must never spawn as a pedestrian). */
/** True while the NPC is in a drive-leg window (suspended from
* the schedule layer: walking to the car, seated, or driving).
* NpcTaxiSystem refuses goals and NpcDetailSystem uses this with
* trafficInfoOf() to decide pedestrian spawning. */
bool isDriving(uint64_t registryId) const;
/** True while the NPC is pinned in the driver seat (NpcDetail
* System's despawn guard). */
bool isSeated(uint64_t registryId) const;
/** Walk target for a driver in the walk-to-car phase: the driver
* door point of their car (world space). False when the driver
* should just stand (waiting for board time) or is not walking
* to a car. */
bool driveTarget(uint64_t registryId, Ogre::Vector3 &outWorld) const;
bool trafficInfoOf(uint64_t driverId, TrafficInfo &out) const;
/** Current symbolic (world-space) position of a car. */
bool carWorldPosition(uint64_t driverId, Ogre::Vector3 &out) const;
@@ -118,9 +167,6 @@ public:
/** Chance a Parking location gets a car (deterministic hash). */
float carOwnershipRatio = 0.5f;
/** Chance a fleet car cruises instead of parking (deterministic
* hash). */
float cruisingRatio = 0.5f;
/** Fleet size cap per town area. */
int maxCarsPerArea = 6;
/** Spawn/despawn distance (squared, world units) camera<->car;
@@ -133,56 +179,125 @@ public:
int spawnBudgetPerFrame = 1;
/** Max simultaneously high-detail traffic vehicles. */
int maxActiveVehicles = 8;
/** Cruiser speed (m/s); also the symbolic tier's advance rate. */
/** Driving speed (m/s); also the symbolic tier's advance rate. */
float cruiseSpeed = 11.0f;
/** Walk-vs-drive threshold (meters, straight-line): longer legs
* are driven when the car is with the driver (phase 6.A). */
float maxWalkDistanceM = 300.0f;
/** Extra seconds per drive leg for pull-out and parking. */
float parkOverheadSeconds = 20.0f;
/** Boarding reach (meters) — same meaning as
* NpcTaxiSystem::boardReachM. */
float boardReachM = 1.8f;
/** Vehicle prefab instantiated for high-detail cars (relative to
* the CWD). */
std::string vehiclePrefab = "prefabs/sedan-traffic.json";
private:
enum class Phase {
Parked = 0,
WalkToCar,
Driving
};
/** Clock-independent drive-leg geometry, cached per location
* pair (dropped on a road graph version change). */
struct LegRoute {
std::vector<AiRoutePoint> route;
std::vector<Ogre::Vector3> routePos;
float length = 0.0f;
/** Doorstep -> parked-car walk distance (meters). */
float boardWalkM = 0.0f;
Ogre::Vector3 originParkPos = Ogre::Vector3::ZERO;
Ogre::Quaternion originParkRot = Ogre::Quaternion::IDENTITY;
Ogre::Vector3 destParkPos = Ogre::Vector3::ZERO;
Ogre::Quaternion destParkRot = Ogre::Quaternion::IDENTITY;
/** Route finding failed: the leg is walked. */
bool unusable = false;
};
struct TrafficCar {
uint64_t driverId = 0;
int areaIdx = -1;
/** Home Parking location name (TownLocations). */
/** Home Parking/Curb location name (TownLocations). */
std::string homeParking;
/* Parked placement (world space; nose along the road
/** Driver's home SCHEDULE location name ("home:<addr>") —
* the car's parked-at chain starts here every day. */
std::string homeLocation;
/* Home spot placement (world space; nose along the road
* edge). */
Ogre::Vector3 parkedPos = Ogre::Vector3::ZERO;
Ogre::Quaternion parkedRot = Ogre::Quaternion::IDENTITY;
bool cruising = false;
/* Cruising loop: world-space, right-lane-offset and
* junction-classified; routePos mirrors the point
* positions for the polyline ops. */
std::vector<AiRoutePoint> route;
std::vector<Ogre::Vector3> routePos;
float routeLength = 0.0f;
/* Meters along the loop (symbolic ground truth). */
/* Symbolic state — re-derived from the clock every
* frame by evalSymbolic(). */
Phase phase = Phase::Parked;
Ogre::Vector3 symbolPos = Ogre::Vector3::ZERO;
Ogre::Quaternion symbolRot = Ogre::Quaternion::IDENTITY;
/** Meters along leg->routePos while Driving. */
float progress = 0.0f;
int laps = 0;
const LegRoute *leg = nullptr;
std::string legFrom;
std::string legTo;
double walkDepAbs = 0.0;
double driveDepAbs = 0.0;
double arrivalAbs = 0.0;
bool suspended = false;
/** Foot walk for a drive leg suppressed; the driver
* waits at the origin until walkDepAbs. Re-derived
* from the clock every frame (no latch). */
bool waitingToDepart = false;
/* High-detail tier. */
flecs::entity vehicle;
flecs::entity driverSpawner;
bool highDetail = false;
bool driverSeated = false;
bool aiAttached = false;
int seatIdx = -1;
Ogre::Vector3 savedCharScale = Ogre::Vector3::UNIT_SCALE;
float dwell = 0.0f;
};
void seedArea(int areaIdx);
bool buildRoute(TrafficCar &car, const RoadGraph &graph);
void advanceSymbolic(TrafficCar &car, float dt);
bool parkedPlacement(const TownLocations::NpcLocation &park,
const RoadGraph *graph, Ogre::Vector3 &pos,
Ogre::Quaternion &rot) const;
const TownLocations::NpcLocation *parkingFor(
const TownLocations::LocationSet &locs, const TrafficCar &car,
const std::string &locName) const;
const LegRoute *legRoute(TrafficCar &car, const std::string &fromLoc,
const std::string &toLoc,
const RoadGraph &graph);
void evalSymbolic(TrafficCar &car, double tabs, float tod,
const RoadGraph *graph);
void setSuspended(TrafficCar &car, bool susp);
void stepHighDetail(TrafficCar &car);
void attachAi(TrafficCar &car);
void alightDriver(TrafficCar &car);
Ogre::Vector3 doorWorld(const TrafficCar &car) const;
flecs::entity driverEntity(const TrafficCar &car) const;
bool ownDriver(const TrafficCar &car, flecs::entity ch) const;
flecs::entity createDriverSpawner(uint64_t registryId);
bool spawnHighDetail(TrafficCar &car);
void despawnHighDetail(TrafficCar &car);
void seatDriver(TrafficCar &car, flecs::entity character);
void releaseDriver(TrafficCar &car, bool despawnCharacter);
size_t routeIndexAt(const TrafficCar &car, float progress) const;
float progressAt(const TrafficCar &car,
const Ogre::Vector3 &pos) const;
size_t routeIndexAt(const LegRoute &leg, float progress) const;
float progressAt(const LegRoute &leg, const Ogre::Vector3 &pos) const;
Ogre::Vector3 cameraPosition() const;
flecs::world &m_world;
EditorApp *m_editorApp;
std::map<uint64_t, TrafficCar> m_cars; /* key: driver registry id */
std::set<int> m_seededAreas;
/* Leg route cache (clock-independent geometry), flushed whole on
* a graph version change or overflow. */
std::map<std::string, LegRoute> m_legRoutes;
uint64_t m_graphVersion = 0;
static const size_t kLegRouteCacheCap = 128;
/* Clock jump detection (scrubs reset the high-detail tier). */
double m_lastTabs = 0.0;
bool m_haveTabs = false;
static NpcTrafficSystem *ms_instance;
};