Phase 4.7: in progress

This commit is contained in:
2026-10-11 22:23:43 +03:00
parent 00b2d5d1fe
commit d28fce7631
42 changed files with 3516 additions and 87 deletions
+31 -2
View File
@@ -32,7 +32,15 @@
# chassis frame). See assets/blender/vehicles/CAR_MODELING.md.
#
# Usage:
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.vehicle.json
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb \
# <out>.vehicle.json [downforce]
#
# downforce (optional, default 25): aerodynamic downforce coefficient
# (N per (m/s)^2, straight down at the COM while moving) — the
# speed-dependent grip lever: at speed it multiplies the tire normal
# load (and thus the lateral impulse budget) without adding inertia,
# which is exactly where plain tires start understeering. Higher =
# tighter turns at speed.
import os
import sys
@@ -58,6 +66,7 @@ except:
glb_file = argv[0]
json_file = argv[1]
downforce = float(argv[2]) if len(argv) > 2 else 25.0
outdir = os.path.dirname(glb_file)
base = os.path.splitext(os.path.basename(bpy.data.filepath))[0]
@@ -294,7 +303,9 @@ SUSP_MAX = 0.35
# Softer than the 1.8/0.7 starting point: gives the body some slack
# over bumps instead of pogo-stiff wheels (which also cost grip).
SUSP_FREQ = 1.3
SUSP_DAMP = 0.6
# Critical-ish damping: less and the body rocks for seconds after a
# bump (the "almost endlessly bouncy" report).
SUSP_DAMP = 1.0
SUSP_REST = SUSP_MAX - 9.81 / (2.0 * math.pi * SUSP_FREQ) ** 2
for w in sorted(wheel_info, key=lambda w: w["pos"].y):
front = w["pos"].y < axle_mid_y
@@ -320,6 +331,17 @@ for s in seat_info:
"driver": s["driver"],
})
# Center of mass: a box collider puts the COM at the box center — for
# a sedan that is ~1 m above the tire contact patches (twice the real
# value) and the car trips onto its side in hard corners (the taxi,
# with its taller box, tips even easier). Shift the COM to just above
# wheel-hub height instead (Jolt OffsetCenterOfMassShape: only the
# physics COM moves — geometry, wheel attachments and the render node
# stay put).
collider_center_ogre_y = (smx.z + smn.z) / 2.0
hub_ogre_y = sum(w["pos"].z for w in wheel_info) / len(wheel_info)
com_offset_y = round(hub_ogre_y + 0.1 - collider_center_ogre_y, 4)
driver_offset = ogre_vec3(driver_seats[0]["pos"])
children = []
@@ -410,6 +432,13 @@ vehicle = {
# soft 1.3 Hz springs a full-lock turn otherwise rolls the car past
# 45 deg onto two wheels.
"antiRollBarStiffness": 2000.0,
"centerOfMassOffset": {"x": 0.0, "y": com_offset_y, "z": 0.0},
# Aerodynamic downforce (N per (m/s)^2, straight down at the COM
# while moving): presses the car onto its suspension at speed —
# less bounce, and a bigger lateral impulse budget exactly where
# plain tires start understeering (turn radius stops growing with
# speed). Per-model value from the CMake invocation.
"downforceCoefficient": downforce,
}
if steering:
vehicle["steeringWheelMeshName"] = base + "-steering-wheel.mesh"
+5 -1
View File
@@ -28,6 +28,10 @@ endforeach()
# generated scene-entity JSON fragment (<name>.vehicle.json, see
# assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md).
set(VEHICLES_GEN_SRC sedan-v2.blend sedan-v2-taxi.blend)
# Per-model downforce coefficient (third gen_vehicle.py argument):
# heavier/taller handling gets more speed-grip.
set(DOWNFORCE_sedan-v2 35)
set(DOWNFORCE_sedan-v2-taxi 45)
foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE)
@@ -39,7 +43,7 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE}
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} ${DOWNFORCE_${FILE_NAME}}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND touch ${VEHICLE_OUTPUT_FILE}
+86
View File
@@ -520,6 +520,92 @@ bool isSpawnerLocked(flecs::entity spawner) const;
Spawned characters have `EditorMarkerComponent` removed and are removed from
`EditorUISystem` caches so they don't appear in editor lists.
### NpcPopulationSystem (demo-taxi phase 4.7.2)
Deterministic town populations in the `CharacterRegistry`. The pure generator
`roadlib/TownPopulation::generatePopulation()` (TownResult + PopulationRules +
seed, headless-testable — probe `runTownPopulationProbe` in
road_geometry_overlap_test) creates one household per building with
attribute-driven members (sex/age band/ageYears/outfitLevel) and seeded
directed relationships (spouse/parent/child/sibling); every building draws
from its own RNG stream (`popSeed ^ fnv1a(address)`, popSeed =
`area.seed ^ 0x9e3779b9`) and gets its own registry id band
(`idBase + building * HOUSEHOLD_ID_CAP(64) + member`, `idBase =
NPC_ID_BASE(1<<40) + areaIndex * AREA_POP_CAP(1<<20)`; household group ids at
`idBase + AREA_POP_CAP/2 + household`), so editing one lot never shifts
another lot's household or ids. `NpcPopulationSystem` (singleton
`getInstance()`, owned by EditorApp) re-runs the area's PURE town generation
(`TownGenSystem::runAreaGeneration`, no entities) so the population always
matches the generated/streamed buildings, and applies it as runtime-only
registry entries (`npcKey`/`npcHome`/`npcArea` string columns, symbolic
position at the home building); regenerating an area replaces exactly its
recorded ids. Registry additions for this: `GroupRecord::persistent`
(runtime groups are skipped by the serializer, as are relationships with
runtime/dangling endpoints), `ensureRuntimeCharacter`/`ensureRuntimeGroup`
(caller-chosen ids >= 1<<40), and `setAutoSaveSuspended` for bulk application.
Editor: Town Generator panel "Generate Population"; CLI:
`--generate-town-population` (implies `--generate-town-areas`, prints the NPC
count headlessly).
### TownLocations + NpcLocationComponent (demo-taxi phase 4.7.3)
The symbolic location model. `roadlib/TownLocations::buildLocations()`
(pure, headless — probe `runTownLocationsProbe` in
road_geometry_overlap_test) builds a per-town `LocationSet`: one Home per
building (at the building center), one Curb per pickup POI and one Parking
per parking POI (`TownGen::TownPoi` has a `kind` field — Pickup/Parking — no
name-suffix parsing), plus an Interest location per building whose hash roll
(`seed ^ fnv1a(address)`, no shared stream) passes the interest ratio;
Work/Leisure kinds exist but nothing generates them yet.
`NpcPopulationSystem` keeps the set per area (`areaLocations(areaIndex)`) and
writes deterministic per-NPC bindings into registry stringColumns: `npcWork`
(from the Interest locations — the workplace placeholder until zoning
exists), `npcInterest` (Interest+Curb), both falling back to the home
location, which is `"home:" + npcHome`. Authored locations use
`NpcLocationComponent` (kind as int mirroring `LocationKind`, name, radius;
serialized as `npcLocation`, editor widget in the Game group); merging
authored markers into the generated set is deferred to the motion phase.
Layout support: the DSL `room` verb has a `:type <name>` option and
`BuildingPlan::roomTypes` parallels `roomNames`; room names/ids/types now
register in BOTH shell modes (in filled-shell mode this is metadata only, no
geometry change). Filled-shell room rects use the rooms-first half-open
`lround(frac * cells)` snapping so fraction-neighboring rooms always share a
boundary index and `idoor` works at any shell size. The default layout
declares a typed living room + bedroom on floor 0 with an internal door (the
bed invariant for phase 8 interiors); exterior geometry and the TownGen RNG
draw order are unchanged.
### TownSchedule + GameClockSystem (demo-taxi phase 4.7.4)
`roadlib/TownSchedule::generateSchedules()` (pure — probe
`runTownScheduleProbe`) builds one schedule per NPC tiling [0, 24) with
(location, activity) entries: sleep/home at the home location, a work block
for adults at the work binding, an optional errand block at the interest
binding (children/elders take a daytime interest visit instead of work).
Per-NPC mt19937 streams seeded `seed ^ fnv1a(stableKey)` keep lot edits
isolated. The location bindings live in the shared pure
`TownLocations::computeBindings()`; the candidate picks are rendezvous-hashed
(highest-random-weight over candidate names), so a location appearing or
vanishing rebinds only the NPCs that referenced it. `NpcPopulationSystem`
stores the schedules per area — `scheduleOf(registryId)` +
`TownSchedule::entryAt(schedule, timeOfDay)` give the current goal.
Verification: `NpcScheduleDebugOverlay` (game mode, toggled with **F7**) shows
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.
`GameClockSystem` (EditorApp-owned singleton `getInstance()`) is the one
queryable clock: day counter + time-of-day hours, pauseable, speed in
game-hours per real second, `setTime(day, hours)`. With `drivesSun` (the
default) it adopts the first SunComponent's `timeOfDay`/`timeSpeed` on sight
and writes the clock time to every sun each frame; an external edit (the sun
panel sliders, which deliberately do NOT markDirty — dirty means a full
light/sphere rebuild) is detected by divergence from the last written value
and re-adopted. `EditorSunSystem` skips its own advance while driven.
Demos without a GameClockSystem behave exactly as before.
### EditorSkyboxSystem / EditorSunSystem
The procedural sky (`Skybox/Dynamic` material) is rendered as a fullscreen
+11
View File
@@ -32,6 +32,9 @@ set(EDITSCENE_SOURCES
systems/RoadRegionStore.cpp
systems/RoadSystem.cpp
systems/TownGenSystem.cpp
systems/NpcPopulationSystem.cpp
systems/GameClockSystem.cpp
systems/NpcScheduleDebugOverlay.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -92,6 +95,8 @@ set(EDITSCENE_SOURCES
components/PushableModule.cpp
ui/TargetZoneEditor.cpp
components/TargetZoneModule.cpp
ui/NpcLocationEditor.cpp
components/NpcLocationModule.cpp
systems/ZoneSystem.cpp
systems/GameHudSystem.cpp
systems/EventBus.cpp
@@ -851,6 +856,12 @@ add_library(RoadGeometryLib STATIC
roadlib/RoadNetworkGen.hpp
roadlib/TownGen.cpp
roadlib/TownGen.hpp
roadlib/TownPopulation.cpp
roadlib/TownPopulation.hpp
roadlib/TownLocations.cpp
roadlib/TownLocations.hpp
roadlib/TownSchedule.cpp
roadlib/TownSchedule.hpp
)
target_include_directories(RoadGeometryLib PUBLIC
+38
View File
@@ -26,6 +26,9 @@
#include "systems/ProceduralMeshSystem.hpp"
#include "systems/CharacterSlotSystem.hpp"
#include "systems/CharacterSpawnerSystem.hpp"
#include "systems/NpcPopulationSystem.hpp"
#include "systems/GameClockSystem.hpp"
#include "systems/NpcScheduleDebugOverlay.hpp"
#include "components/CharacterSpawner.hpp"
#include "systems/TerrainPrefabSpawnerSystem.hpp"
#include "components/TerrainPrefabSpawner.hpp"
@@ -110,6 +113,7 @@
#include "components/Actuator.hpp"
#include "components/Pushable.hpp"
#include "components/TargetZone.hpp"
#include "components/NpcLocation.hpp"
#include "components/GoapPlanner.hpp"
#include "components/GoapRunner.hpp"
#include "components/PathFollowing.hpp"
@@ -196,6 +200,10 @@ void ImGuiRenderListener::preViewportUpdate(
gameHud->update(m_deltaTime);
gameHud->render();
}
NpcScheduleDebugOverlay *npcDbg =
m_editorApp->getNpcScheduleDebugOverlay();
if (npcDbg)
npcDbg->render();
}
/* Scene-switch loading cover: fullscreen black overlay for the
@@ -426,6 +434,7 @@ void EditorApp::destroyEditorSystems()
m_animationTreeSystem.reset();
m_characterSlotSystem.reset();
m_characterSpawnerSystem.reset();
m_npcPopulationSystem.reset();
/* Despawn prefab instances before terrain/physics teardown. */
m_terrainPrefabSpawnerSystem.reset();
m_proceduralMeshSystem.reset();
@@ -560,6 +569,10 @@ void EditorApp::setup()
/* F12 game HUD overlay (ImGui, Lua-driven). */
m_gameHudSystem = std::make_unique<GameHudSystem>(this);
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
m_npcScheduleDebugOverlay =
std::make_unique<NpcScheduleDebugOverlay>(this);
// Setup buoyancy system (requires physics system)
// Get the physics wrapper from the physics system
m_buoyancySystem = std::make_unique<BuoyancySystem>(
@@ -657,6 +670,17 @@ void EditorApp::setup()
m_uiSystem.get());
m_characterSpawnerSystem->initialize();
/* Town population (demo-taxi phase 4.7.2): deterministic
* per-area NPC households in the CharacterRegistry. */
m_npcPopulationSystem =
std::make_unique<NpcPopulationSystem>(m_world);
/* Game clock (demo-taxi phase 4.7.4): the queryable
* day + time-of-day source for the schedule layer;
* drives the sun so visuals and schedules agree. */
m_gameClockSystem =
std::make_unique<GameClockSystem>(m_world);
// Setup TerrainPrefabSpawner system (M6): needs the camera
// system for distance checks and physics for body cleanup on
// despawn; must exist before the terrain system updates.
@@ -2073,6 +2097,9 @@ void EditorApp::setupECS()
// Register Target Zone component (F11)
m_world.component<TargetZoneComponent>();
// Register NPC Location marker component (demo-taxi phase 4.7.3)
m_world.component<NpcLocationComponent>();
// Register Event Handler component
m_world.component<EventHandlerComponent>();
@@ -2512,6 +2539,9 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
/* --- Rendering support systems --- */
/* The game clock runs first: it drives the sun's timeOfDay. */
if (m_gameClockSystem)
m_gameClockSystem->update(fixedEvt.timeSinceLastFrame);
if (m_sunSystem) {
Ogre::Camera *cam = nullptr;
if (m_sceneMgr->hasCamera("PlayerCamera"))
@@ -2750,6 +2780,14 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
return true;
}
/* F7 toggles the NPC schedule debug overlay (demo-taxi
* phase 4.7.4). */
if (evt.keysym.sym == OgreBites::SDLK_F7 && !evt.repeat) {
if (m_npcScheduleDebugOverlay)
m_npcScheduleDebugOverlay->toggle();
return true;
}
/* Ignore repeated keydowns for movement keys. Releasing Shift
* while W/A/S/D is held can generate a repeated keydown with a
* different symbol after the physical release, which would
+19
View File
@@ -17,6 +17,7 @@ class EditorUISystem;
class VehicleSystem;
class ZoneSystem;
class GameHudSystem;
class NpcScheduleDebugOverlay;
class EditorCamera;
class EditorPhysicsSystem;
class EditorLightSystem;
@@ -28,6 +29,8 @@ class ProceduralMaterialSystem;
class ProceduralMeshSystem;
class CharacterSlotSystem;
class CharacterSpawnerSystem;
class NpcPopulationSystem;
class GameClockSystem;
class TerrainPrefabSpawnerSystem;
class AnimationTreeSystem;
class HairPhysicsSystem;
@@ -329,6 +332,14 @@ public:
{
return m_characterSpawnerSystem.get();
}
NpcPopulationSystem *getNpcPopulationSystem() const
{
return m_npcPopulationSystem.get();
}
GameClockSystem *getGameClockSystem() const
{
return m_gameClockSystem.get();
}
PlayerControllerSystem *getPlayerControllerSystem() const
{
return m_playerControllerSystem.get();
@@ -353,6 +364,10 @@ public:
{
return m_gameHudSystem.get();
}
NpcScheduleDebugOverlay *getNpcScheduleDebugOverlay() const
{
return m_npcScheduleDebugOverlay.get();
}
/* Shared Lua state (tests/tools; scripts access it via the ecs API). */
editScene::LuaState *getLuaState()
{
@@ -448,6 +463,8 @@ private:
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<CharacterSpawnerSystem> m_characterSpawnerSystem;
std::unique_ptr<NpcPopulationSystem> m_npcPopulationSystem;
std::unique_ptr<GameClockSystem> m_gameClockSystem;
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
@@ -476,6 +493,8 @@ private:
std::unique_ptr<VehicleSystem> m_vehicleSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
std::unique_ptr<NpcScheduleDebugOverlay> m_npcScheduleDebugOverlay;
/* F10 enter/exit + drive input for vehicles (game mode). */
std::unique_ptr<VehicleControllerSystem> m_vehicleControllerSystem;
@@ -1913,6 +1913,18 @@ Per-room window suppression: `(room name (rect ...) :windows none)`
flags the room (bathroom/storage), and both window passes
(perimeter walk and union-boundary) skip its cells — declare rooms
before the `windows` verb; probe-verified in both shell modes.
Room types (phase 4.7.3): `(room name (rect ...) :type bedroom)`
tags the room in `BuildingPlan::roomTypes` (parallel to
`roomNames`), and room names/ids/types now register in BOTH shell
modes — in filled-shell mode this is metadata only (roomIds filled,
no geometry change) so the location/interior generators can read
rooms from any layout. Filled-shell room rects use the rooms-first
half-open `lround(frac * cells)` snapping, so fraction-neighboring
rooms always share a boundary index and `idoor` works at any shell
size (odd cell counts included). The default layout declares a
typed living room + bedroom on floor 0 with an internal door (the
bed invariant); exterior geometry and the TownGen RNG draw order are
unchanged.
Regen/clear teardown is synchronous (`TownGenSystem::destroyEntityDeep`
— children first, then physics body, TriangleBuffer Ogre entity +
mesh, renderable, settlement material/texture, scene node last); the
@@ -0,0 +1,33 @@
#ifndef EDITSCENE_NPC_LOCATION_HPP
#define EDITSCENE_NPC_LOCATION_HPP
#pragma once
#include <Ogre.h>
/**
* NPC location marker component (demo-taxi phase 4.7.3).
*
* Authors a symbolic location by hand: an entity with this component
* marks a named place NPCs can bind to and travel between (their
* TransformComponent position is the location position). Generated
* town locations come from roadlib/TownLocations; authored markers
* are for places the generator cannot know (a bus stop, a taxi rank,
* a landmark) and are merged into the location set by the population
* / motion systems.
*
* `kind` mirrors TownLocations::LocationKind as an int so the
* component stays free of the roadlib dependency:
* 0 = home, 1 = work, 2 = leisure, 3 = interest, 4 = curb,
* 5 = parking.
*/
struct NpcLocationComponent {
/* TownLocations::LocationKind as int (see above). */
int kind = 3; /* Interest */
/* Unique location name (the binding key stored on NPCs). */
Ogre::String name;
/* Arrival radius in meters (how close is "at" the location). */
float radius = 2.0f;
bool enabled = true;
};
#endif // EDITSCENE_NPC_LOCATION_HPP
@@ -0,0 +1,20 @@
#include "NpcLocation.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/NpcLocationEditor.hpp"
REGISTER_COMPONENT_GROUP("NpcLocation", "Game", NpcLocationComponent,
NpcLocationEditor)
{
registry.registerComponent<NpcLocationComponent>(
"NpcLocation", "Game", std::make_unique<NpcLocationEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<NpcLocationComponent>())
e.set<NpcLocationComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<NpcLocationComponent>())
e.remove<NpcLocationComponent>();
});
}
@@ -85,6 +85,18 @@ foreach(DEMO_ROAD_REGION_FILE ${DEMO_ROAD_REGION_FILES})
COPYONLY)
endforeach()
# Sync-back helper for the source-authoritative region files above:
# edits made while running from the build directory (lot params, layout
# DSL, road tweaks saved by the editor) live only in the staging copy
# until they are copied back, and the next configure overwrites them.
# Run `cmake --build build-vscode --target sync_road_regions` after
# editing roads/lot params in the editor, then commit the source files.
add_custom_target(sync_road_regions
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/roads"
"${CMAKE_CURRENT_SOURCE_DIR}/heightmaps/4242424300000001/roads"
COMMENT "Syncing road region files back to the source tree")
add_executable(demoTaxi
demo_main.cpp
${DEMO_SOURCES}
+394 -25
View File
@@ -1573,7 +1573,7 @@ lots assigned by centroid — no building store needed); town areas on
when streaming). Implementation order: §21.6 (edge params -> DSL ->
splitting -> areas -> per-page streaming).
### Phase 4.6 — Room-composed buildings + per-building regeneration
### Phase 4.6 — Room-composed buildings + per-building regeneration (COMPLETE 2026-10-10, verified in the editor)
**Rooms-first layout DSL (additive, complements shell+cut).** The
shell declares the overall building rect, rooms fill it by relative
@@ -1667,10 +1667,11 @@ button next to the edge-side Layout field (shared core
gained live DSL validation (`TerrainEditor::checkLayoutSource()` —
representative bindings, scratch RNG; red error / yellow warnings /
green "OK: NxM cells, F floor(s)" under the field), a "Pop Out" button
opening a floating 16 KB editor bound to the edge by node ids
opening a floating editor bound to the edge by node ids
(`renderLayoutPopout()`, with its own validation, template insert and
Regenerate), and the Town Generator panel lists the last run's
warnings (`TownGenSystem::getLastWarnings()`). New probe in
Regenerate; its initial fixed 16 KB buffer was replaced by direct
`RoadLotParams::layout` editing on 2026-10-10, see below), and the
Town Generator panel lists the last run's warnings (`TownGenSystem::getLastWarnings()`). New probe in
`runTownGenProbe()`: a layout edit on one edge side leaves every other
building bit-identical and `paramsEdge`/`paramsSide` are recorded for
all 12 buildings of the probe town.
@@ -1739,28 +1740,396 @@ Status (2026-10-10, density modest variant + editor/DSL fixes):
The CellGrid shell was unaffected — it binds the material by NAME,
which Ogre resolves once the material exists.
### Phase 4.7 NPC motion
- it is about general abstract NPC motion simulation in exterior scene, to add life to the scene and still be believable.
- the complicates comes from the goal of the motion to be beliavable so it should have a reason.
- some of features needed for this are either implemented or partially implemented on NPC level and should be found, if any and reused or modified or discarded i unfeasible within a reason.
- There is GOAP implementation in the tree, but if HTN is superior, it should be implemented.
- the function is centered around NPC having a target to get to.
- should be controlled by dedicated system
- NPCs are deterministic and are generated by the town using per-lot settings
- House bed count should be equal or more to NPC count. The bed should be registered to NPC (or bed side for bigger/composite beds) (after we start handling interiors)
- NPCs are defined as families/groups with members having relationships set
- NPCs name/sex/stats/needs growths/etc. values are set there too. Whatever not set is generated. Rules should be per-town.
- NPCs are generated via character registry by creating dynamic entries but using pre-defined registry character can be used.
- Town influences NPC schedule by setting their goal.
- NPC motion system tracks NPC motion by symbolic locations like home/work/leisure/interest, etc. which are defined per NPC and generated if not defined.
- NPC can be at the location running action/smart object or between them.
- If NPC is in between, they join traffic (phase 5 or 6)
- The motion simulation can be low detail or high detail. Low detail simulation just changes NPC world position and applys effects, no NPC spawned. High detail simulation spawns NPC and or the vehicle and provides full simulation.
- Traffic connects locations to each other. The path between locations is generated using AStar, using navmesh or other algorithms.
- Traffic is used only for exterior. Interior uses completely different systems based on GOAP or HTN and behavior trees. NPC motion system uses goals and delivers NPCs to simulate live town being believable but without complex computations. Basically it simulates NPC presence and motion at location at time so if player gets to that location and NPC is moving to the place they see NPC walking or driving. NPCs should realistically leave buildings, enter vehicles or walk, park and leave vehicles ane enter buildings. Entering building means NPC despawn from exterior scene by activating the front door. Not all targets are inside buildings, exterior locations should be supporterd too. It should be easy to author locations (interior/exterior).
- Taxi mission should have NPCs near player to have special goal of using the taxi and waiting to be picked up. However out of the mission, NPCs should be able to call the taxi and transit by it.
### Phase 4.7 — NPC motion (believable exterior life)
**Goal.** A general, abstract NPC motion simulation for the exterior
scene: the town feels alive and stays believable when observed.
Believability is the hard part — every NPC's motion must have a
*reason* (a goal and a place it connects), not random wandering. The
simulation must stay cheap with a whole town population: full detail
only exists near the player, everything else is symbolic.
**Design principles.**
- Every NPC is goal-centered: at any moment an NPC is either AT a
symbolic location (running an action/smart object there) or IN
TRANSIT between two locations. No free-floating NPCs.
- Two detail tiers. LOW: the NPC is not spawned — the simulation just
advances its world position along a path and applies effects (needs
decay, schedule progress). HIGH: the NPC (and later its vehicle,
phase 5) is spawned and fully simulated. Crossing INTO high detail
mid-transit spawns the NPC on the path; crossing out mid-transit
despawns back to the symbolic position — both must look seamless.
- Deterministic: population, schedules and positions at time T are a
pure function of the town seed and T (same guarantee style as
TownGen's per-lot streams), so save/load and regen agree.
- Exterior only. Interiors run on completely different systems
(GOAP/HTN + behavior trees, phase 8+); this phase only has to make
enter/exit believable (walk to the front door, activate it, despawn
from the exterior — and the reverse on leaving).
**Existing infrastructure — reuse, modify or discard (4.7.1 audit).**
- `CharacterRegistry` (already implemented): records with
name/sex/age/stats/skills/needs/pools, `GroupRecord` for families,
the relationship graph, tags and custom columns, and runtime-only
(non-persisted) entries — the NPC registry requirement is mostly
covered; generated towns create dynamic entries, authored
(persistent) registry characters can be placed per lot explicitly.
- `CharacterIdentityComponent` links a spawned entity to its registry
record; the record's `version` counter already triggers respawns.
- `CharacterSpawnerSystem`: distance-based spawn/despawn — currently
per-spawner distances; this phase adds a GLOBAL configurable
spawn/despawn distance for simulated NPCs with anti-flapping
(despawn distance > spawn distance hysteresis + a minimum dwell
time), reusing the existing lock/force-spawn mechanics.
- Root-motion locomotion and path steering for spawned NPCs are
already implemented (same movement style as the player character:
`AnimationTreeComponent::useRootMotion` + navmesh steering);
low-detail NPCs instead move by direct position updates along the
path.
- `NavMeshSystem` (Recast/Detour) for path queries; the road graph
(nodes/edges/sidewalks, `TownPoi`s) as the coarse A* layer.
- `ActuatorSystem` smart objects for at-location actions; `DoorSystem`
for front-door activation on enter/exit.
- Legacy `src/aitoolkit` GOAP and the legacy CharacterAIModule are
read-only references — harvest ideas, do not modify. The GOAP-vs-HTN
decision is for interior AI (phase 8); this phase's schedule/goal
layer is deliberately simpler (deterministic schedule + current
goal), so HTN is only pulled in if the audit shows goals need real
planning.
**4.7.1 audit outcome (2026-10-10).**
Reuse as-is:
- `CharacterRegistry` (`systems/CharacterRegistry.hpp`) — the data
model already covers this phase: `CharacterRecord` (names,
stats/skills/needs/pools, `age` category string + `ageYears`, tags,
custom int/float/string columns, inline appearance —
`inlineSex`/`inlineOutfitLevel`/`inlineSlotSelections`/
`inlineShapeKeyWeights`, i.e. the look is already attribute-driven),
`GroupRecord` households, runtime-only entries (`persistent=false`,
IDs allocated from `1<<32` up), Markov name generators, the
`version` respawn counter, and `position`/`rotation` write-back on
despawn — the natural symbolic-position store.
- `Relationship` — ALREADY directed (source → target) with
open-ended float `stats` and keyword `tags`, so the 4.7.2
relationship model needs NO schema change, only generation code
seeding `spouse`/`sibling`/`child`/`acquainted`/`friend`.
- PathFollowingSystem + NavMeshSystem + root motion — the
high-detail walk stack is complete (navmesh path corridor, door
auto-open, walk/run animation states); road/sidewalk chunks carry
`NavMeshGeometrySource`, so generated towns are navmesh-covered.
- CharacterSpawnerSystem — O(spawners) distance gate with
lock/force-spawn API; the spawner transform override is the seam
for mid-transit spawn-at-position.
- Current GOAP (`GoapPlannerSystem`/`GoapRunnerSystem` in
src/features/editScene) stays for high-detail at-location actions
of SPAWNED NPCs only — per-entity A* planning is too heavy for a
whole-town population, so the exterior schedule layer is
deterministic instead of planned.
Extend:
- CharacterSpawnerSystem: GLOBAL spawn/despawn distance mode for
population NPCs (overrides per-spawner distances), dwell-time
anti-flapping, and a per-frame spawn budget (prefab instantiation +
CharacterSlotSystem mesh build is the expensive part — a burst
hitches).
- PathFollowingSystem::handleDoorAhead: town front doors are
`lockedByDefault` and scene-switch doors are skipped, so NPCs
currently cannot enter any building. Needs per-NPC door
authorization (residents pass their own door) and an enter/exit
hook (despawn-to-symbolic on entering, spawn-from-symbolic on
leaving).
- TownGenSystem: keep the `TownResult` alive per area/page instead of
discarding it after building creation — the population and location
generators consume it. `TownPoi` gains a kind field
(curb pickup / parking) instead of name-suffix parsing.
- Clock: `SunComponent::timeOfDay` advances by wall-clock deltaTime
with no day counter and no central accessor — the schedule layer
needs a queryable game clock (day + timeOfDay, pauseable, fixed-step
friendly).
- BuildingPlan/DSL: room TYPE metadata (bedroom/...) on top of
roomIds/roomNames; default layouts gain a bedroom (the 4.7.3
bed invariant).
New:
- roadlib `TownPopulation` (pure, headless): TownResult + per-area
population rules + seed → households, attribute-driven members,
seeded relationships, home bindings. Deterministic registry IDs
derived from (area seed, address, member index).
- Location model: symbolic locations (home/work/leisure/curb/
parking) built from TownResult; a location marker component for
authored locations.
- `NpcPopulationSystem` (ECS side): applies TownPopulation to the
CharacterRegistry as runtime-only entries, owns the per-NPC
location bindings.
- `NpcMotionSystem` (4.7.5): AT_LOCATION/IN_TRANSIT symbolic state;
positions a pure function of (schedule, T); coarse A* over the
RoadGraph (an adjacency index + edge-length cost do not exist yet).
Read-only references:
- Legacy `src/aitoolkit` GOAP + `src/gamedata` TownAI — harvest
ideas only (BitEvolutionBuilder-style time-driven need decay,
utility scoring).
- HTN: a full HTN planner framework lives OUTSIDE this repo at
`../../htn_planner` (HTNFramework + domains + editor). It
requires C++20 and must be converted to C++11 before it can be
adopted here; the GOAP-vs-HTN decision is therefore deferred to
the interior AI phase (8) — 4.7's schedule/goal layer needs no
planner.
**Sub-phases.**
- **4.7.1 Reuse audit + planner decision.** COMPLETE 2026-10-10 —
outcome recorded above.
- **4.7.2 Town population generation.** IN PROGRESS (core landed
2026-10-10): `roadlib/TownPopulation` (pure, headless) generates one
household per building — per-building RNG stream seeded
`popSeed ^ fnv1a(address)` (popSeed = `area.seed ^ 0x9e3779b9`), so
editing one lot never shifts another lot's household; deterministic
registry ids from per-building bands (`idBase + building * 64 +
member`, `idBase = NPC_ID_BASE(1<<40) + areaIndex * AREA_POP_CAP(1<<20)`,
household groups at `idBase + AREA_POP_CAP/2 + household`). Members
are attribute-driven (sex, age band + ageYears, outfitLevel) and
households seed the directed spouse/parent/child/sibling
relationships. `NpcPopulationSystem` (singleton,
`TerrainSystem::getTownGenSystem()`-style) applies the population to
the CharacterRegistry as runtime-only entries (`npcKey`/`npcHome`/
`npcArea` string columns, symbolic position at the home building);
regeneration replaces exactly the area's recorded ids. Registry
additions: runtime `GroupRecord`s (`persistent` flag),
`ensureRuntimeCharacter`/`ensureRuntimeGroup` (caller-chosen ids >=
1<<40), an auto-save suspension guard for bulk application, and the
serializer skipping non-persistent groups and relationships with
runtime/dangling endpoints. Editor: Town Generator panel "Generate
Population" button; CLI: `--generate-town-population` (implies
`--generate-town-areas`). Probe: `runTownPopulationProbe` in
road_geometry_overlap_test (determinism, per-building isolation,
composition/relationship/id invariants). Still to do: per-town
RULES living on the town area definition (currently hardcoded
defaults), per-lot household steering from RoadLotParams, and the
authored-lot attribute-rule UI.
TownGen-time (same per-lot
RNG discipline): each built lot gets a household — a `GroupRecord`
family whose members are runtime-only registry entries; per-town
generation RULES (age/sex distribution, names, stats/needs defaults)
live with the town area definition; anything not set by the rules is
generated deterministically. Per-lot settings (RoadLotParams/layout)
steer household size and character; predefined registry characters
can be pinned to a lot instead of generating. Later, once interiors
exist, the invariant is beds >= residents with a concrete bed (or
bed side for composite beds) registered per NPC.
Attribute-driven generation for authored lots: instead of fixed
registry entries, lot params can specify generation RULES for
dynamic characters — sex, age band (child/adult/...), clothing and
further attributes as they are added. Most town NPCs will be
dynamic characters built from such attributes, not hand-authored
registry records; this doubles as the proving ground for the NPC
look system (every new look attribute must become expressible as a
generation rule).
Relationship model (data now, dynamics later): relationships are
DIRECTED — A's record of B is separate from B's record of A — and
carry float stats plus keyword tags, both dynamically extensible
(systems and Lua scripts define new stats/keywords as they come
online). They are not limited to the household GroupRecord —
out-of-group ties exist but are more vague. Seed keywords:
`spouse`, `sibling`, `child`, `acquainted`, `friend`; more are
added by various systems. Inter-character relationship dynamics is
a very complex later topic — this phase only establishes the data
structure so later systems have something to hang on.
- **4.7.3 Location model.** COMPLETE 2026-10-11 (core) — status:
`roadlib/TownLocations::buildLocations()` (pure, headless; probe
`runTownLocationsProbe` in road_geometry_overlap_test) builds the
per-town `LocationSet`: one Home per building (at the building
center), one Curb per pickup POI, one Parking per parking POI
(`TownPoi` gained a `kind` field, replacing name-suffix parsing)
and an Interest location per building whose hash roll passes the
interest ratio (`seed ^ fnv1a(address)` per building — no shared
stream, lot edits stay isolated). Work/Leisure kinds exist but no
zoning generates them yet. `NpcPopulationSystem` stores the set
per area (`areaLocations()`) and writes deterministic per-NPC
bindings into the registry stringColumns: `npcWork` (picked from
the Interest locations — the workplace placeholder until zoning
exists), `npcInterest` (Interest+Curb), both falling back to the
home location; the home location is `"home:" + npcHome`. Authored
locations: `NpcLocationComponent` (kind/name/radius, serialized as
`npcLocation`, editor widget under Game) — merging authored markers
into the generated set is deferred to the motion phase. Room
types: the DSL `room` verb gained `:type <name>`, `BuildingPlan`
gained `roomTypes` (parallel to `roomNames`), and room
names/ids/types now register in BOTH shell modes (filled-shell
registration is metadata-only, no geometry change); the legacy
room-rect snapping switched to the rooms-first half-open
`lround(frac * cells)` convention so fraction-neighboring rooms
always share a boundary index and `idoor` works at any shell size.
The default layout declares a typed living room + bedroom on floor
0 with an internal door (the bed invariant); exterior geometry and
the TownGen RNG streams are unchanged (verified: identical
exterior mesh vertex counts, same building/household counts).
Original text follows.
Symbolic locations — home / work /
leisure / interest / curb — each interior (a building, entered via
its front door) or exterior (a POI, a bus-stop-like marker, a
parking rect). Locations are generated from the town (buildings,
POIs, parking) and must be easy to author manually too (a location
marker component + editor placement). Per-NPC location bindings
(home, work, ...) are generated when not authored.
Bedroom/bed invariant: the population model assumes every home can
sleep its household. Beds materialize only with interior furniture
placement (phase 8+), but the layouts must guarantee the rooms for
them NOW: the default building layout gains at least one bedroom,
sized so every household member has a bed once interiors land.
Room types and furniture-selection keywords therefore become part
of the layout data in this phase (extending the existing
`BuildingPlan::roomIds`/`roomNames`). Rough furniture placement
already exists in Cell Grid for node-based rooms; porting it to
DSL layouts should be easy and is refined later with the interiors
work — for now treat "bedrooms with beds exist" as a given concept
when designing the location bindings.
- **4.7.4 Schedules and goals.** COMPLETE 2026-10-11 (core) —
status: `roadlib/TownSchedule::generateSchedules()` (pure; probe
`runTownScheduleProbe`) builds one schedule per NPC tiling [0, 24)
with (location, activity) entries — sleep/home at the home
location, a work block for adults at the work binding, an optional
errand block (probability in `ScheduleRules`) at the interest
binding; children/elders take a daytime interest visit instead of
work. Per-NPC mt19937 streams seeded `seed ^ fnv1a(stableKey)`
keep lot edits isolated; location bindings moved into the shared
pure `TownLocations::computeBindings()` (used by both the
population system and the schedule generator) and the picks are
rendezvous-hashed, so a location appearing/vanishing rebinds only
the NPCs that referenced it. `NpcPopulationSystem` stores the
schedules per area (`scheduleOf(registryId)`); regeneration via
"Generate Population" / `generateAreaPopulation` is the
deterministic script-forced regen path. Clock: `GameClockSystem`
(EditorApp-owned singleton) is the queryable clock — day counter +
time-of-day, pauseable, speed in game-hours per real second — and
drives every `SunComponent` (adopting the sun's configured
time/speed on sight and re-adopting on divergence, so sun panel
slider edits — deliberately not dirty-flagged — still apply;
`EditorSunSystem` skips its own advance while driven).
Goal injection (the taxi call) arrives with 4.7.7; the goal is
`TownSchedule::entryAt(schedule, clock)` until then. Verification
aid (added by request): a game-mode NPC schedule debug overlay
(`NpcScheduleDebugOverlay`, toggled with **F7**, game mode only) —
clock readout with pause/speed/time scrubber, per-town-area NPC
list with each NPC's current activity/location (schedule entry at
the clock time), and the full 24h schedule + location bindings of
the selected NPC. Original
text follows.
Per-town rules assign each NPC a
time-of-day schedule over its locations; the town can inject goals
(events, the taxi call below). The NPC's current goal = (location,
arrival window, activity). Schedules are generated once with the
town (deterministically, from the town seed), but scripts must be
able to force a regeneration when events demand it — regeneration
stays deterministic as well (same town state + event log → same
schedule).
- **4.7.5 Motion core (low detail).** `NpcMotionSystem` (the
dedicated controlling system): per NPC a state machine
AT_LOCATION / IN_TRANSIT; transit moves the registry position along
an A* path (road graph coarse layer, navmesh fine layer near the
player) at walk/drive speed; arrivals/departures update the symbolic
state and apply effects. Cheap for hundreds of NPCs: no entities,
no animation, just position + state. Open research point: whether
transit paths between locations can be PRE-CACHED — caching all
location pairs is likely infeasible RAM-wise for a whole town, so
investigate the options first (on-demand coarse A* with a bounded
shared LRU cache, per-location path skeletons, caching only for
high-detail NPCs) before committing to a design.
- **4.7.6 High detail tier.** Within the global spawn distance (with
hysteresis + dwell) the NPC spawns via CharacterSpawnerSystem and
walks the remaining path with root motion; leaving buildings uses
the front door (despawn on activation); at a location the NPC runs
the matching smart object/idle action. Transitions mid-path must
not pop (spawn at the exact symbolic position/heading).
- **4.7.7 Taxi integration.** Mission NPCs near the player get the
special goal "taxi": walk to the curb POI, wait for pickup, board,
ride (phase 5 vehicle), alight at the destination location.
Outside missions the same mechanism lets NPCs call the taxi and
transit by it — the player taxi is just one carrier.
- **4.7.8 Debug + verification.** Editor overlay: locations, NPC
symbolic states, paths, current goals; time-of-day scrubber.
Headless probes: population determinism (same town seed → identical
registry diff, including attribute-rule-generated dynamic characters
and the seeded relationship records), schedule determinism (same T →
same positions),
spawn/despawn hysteresis (no flapping at the boundary), path
validity (transit paths stay on roads/navmesh), and the taxi-goal
flow in low and high detail.
**Boundaries.** NPC-DRIVEN vehicles and vehicle traffic are phase 5;
foot crowd ambience is phase 6; interior behavior is phase 8. This
phase delivers the exterior simulation and the seams those phases plug
into.
**Manual verification (as of 4.7.3).** Headless, from the project
root:
```sh
cmake --build build-vscode --target editSceneEditor \
road_geometry_overlap_test cellgrid_door_test \
component_lua_test -j4
cd build-vscode/src/features/editScene
./road_geometry_overlap_test # incl. runTownPopulationProbe /
# runTownLocationsProbe /
# runTownScheduleProbe, exit 0
./cellgrid_door_test # 13/13
./component_lua_test # 66/66
./editSceneEditor --project demos/demo-taxi \
--editor demo_scene_exterior.json --generate-town-population
# expect: "generate-town: 9 buildings",
# "NpcPopulationSystem[Area 1]: 33 NPCs in 9 households" and an
# unchanged exterior (identical CellGrid extWall/extWindows vertex
# counts against a pre-change run)
```
In the editor (`--project demos/demo-taxi --editor
demo_scene_exterior.json`):
1. **Population:** Town Generator panel → "Generate Population" — the
log prints the per-area NPC/household counts. Open the character
registry: runtime NPC entries carry `npcKey`/`npcHome`/`npcArea`/
`npcWork`/`npcInterest` string columns and spouse/parent/child/
sibling relationship tags; household groups appear as
`<address> household`. Save and confirm
`character_registry.json` gains NONE of them (runtime-only).
2. **Regeneration stability:** press "Generate Population" twice —
the NPC count stays identical (exact replacement, no leaks).
3. **Rooms/types:** Tools → Layout/DSL editor on a lot's edge params
— the default layout now declares `living` + `bedroom` rooms with
an internal door; regenerating a single building changes no
exterior. `(room bath (rect ...) :windows none :type bathroom)`
must parse; `idoor` between fraction-neighboring rooms works at
odd shell sizes.
4. **Authored locations:** add `NpcLocation` (Game group) to an
entity in the property panel, set kind/name/radius, save the scene
and reload — the component round-trips (`npcLocation` in the scene
JSON).
5. **Schedules + clock (4.7.4):** run the demo in game mode and
press **F7** — the NPC schedule overlay shows the clock (day,
time-of-day, speed), lets you pause/scrub time, and lists every
town NPC with the schedule entry active at the current time
(activity + location); selecting an NPC shows its full 24h
schedule and home/work/interest bindings. Scrubbing through the
day should move NPCs between home/work/errand entries; the sun
follows the same clock. Editing `timeOfDay`/`timeSpeed` in the
editor's sun panel still works (re-adopted by divergence).
Headless coverage: `runTownScheduleProbe` (tiling, bindings,
determinism, per-NPC isolation).
### Phase 5 — NPC vehicle traffic
- the NPC owns the vehicle, the vehicles are registered to the NPC as driver. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations.
- the NPC owns the vehicle, the vehicles are registered to the NPC as driver the same way as NPCs are added in town. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations.
- the NPC can choose by distance if vehicle is used or going by feet (phase 8).
- the vehicle is used when NPC has one parked near their current location.
- NPC position is simulated in varying levels of detail
@@ -849,15 +849,15 @@
},
"transform": {
"position": {
"x": 3997.240966796875,
"y": 10.873899459838867,
"z": 3960.30029296875
"x": 3996.921875,
"y": 10.871692657470703,
"z": 3960.160400390625
},
"rotation": {
"w": 0.9887836575508118,
"x": -0.05630514770746231,
"y": -0.13825948536396027,
"z": -0.004587905947118998
"w": 0.9876378178596497,
"x": -0.05634903535246849,
"y": -0.14618928730487823,
"z": -0.005017466377466917
},
"scale": {
"x": 1.0,
@@ -1254,15 +1254,15 @@
},
"transform": {
"position": {
"x": 4014.4697265625,
"y": 10.973102569580078,
"z": 3965.343017578125
"x": 4014.4599609375,
"y": 10.973583221435547,
"z": 3965.41259765625
},
"rotation": {
"w": 0.7457117438316345,
"x": -0.057677898555994034,
"y": 0.6619836091995239,
"z": 0.04863201826810837
"w": 0.7366631627082825,
"x": -0.057442210614681244,
"y": 0.672011137008667,
"z": 0.04928449168801308
},
"scale": {
"x": 1.0,
@@ -1608,7 +1608,7 @@
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 23.656871795654297,
"timeOfDay": 2.1072239875793457,
"timeSpeed": 0.12999999523162842
},
"transform": {
@@ -4,6 +4,19 @@
"lanesAtoB": 2,
"lanesBtoA": 2,
"lanesPerDirectionOverride": 0,
"lotLeft": {
"buildable": 0,
"densityScale": -1.0,
"district": -1,
"layout": "(let ((w 20) (d 25))\n(shell w d :floors 1 :empty)\n(floor 0\n (room corridor1 (rect 0 0 1 0.2))\n (room livingroom1 (rect 0 0.2 0.35 1))\n (room bedroom1 (rect 0.65 0.2 1 1))\n (connect corridor1 livingroom1)\n (connect corridor1 bedroom1)\n (door back -7)\n (windows all))\n(roof normal 0 0 w 5)\n(roof normal 0 5 7 d)\n(roof normal 13 5 w d))\n",
"maxFloors": 0,
"maxFootprint": 0.0,
"maxFrontage": 0.0,
"minFloors": 0,
"minFootprint": 0.0,
"roofType": -1,
"setback": -1.0
},
"nodeA": 1,
"nodeB": 2,
"roadLevelA": 0.0,
@@ -586,7 +599,7 @@
},
{
"id": 8,
"verticalOffset": 0.16122817993164063,
"verticalOffset": 0.16122817993164062,
"x": 3690.21337890625,
"y": 8.126683235168457,
"z": 3682.84912109375
@@ -828,4 +841,4 @@
2
],
"version": "1.0"
}
}
@@ -107,13 +107,17 @@ struct BuildingPlan {
std::vector<uint64_t> cells;
/**
* Room assignment per cell (same indexing as cells, -1 = none)
* plus the room name table (roomIds index into it). Filled only
* by rooms-first layouts (`shell ... :empty`, phase 4.6); empty
* for shell+cut layouts. Phase 8's interior generator reads the
* rooms from here.
* plus the room name/type tables (roomIds index into them).
* Rooms-first layouts (`shell ... :empty`, phase 4.6) always fill
* them; shell+cut layouts fill them too when they declare rooms
* (metadata only there — roomIds has no geometry effect in that
* mode). roomTypes carries the optional `:type` tag per room
* ("" = untyped; e.g. "living"/"bedroom", phase 4.7.3). Phase
* 8's interior generator reads the rooms from here.
*/
std::vector<int> roomIds;
std::vector<std::string> roomNames;
std::vector<std::string> roomTypes;
std::vector<PlanRoof> roofs;
std::vector<PlanParking> parkings;
std::vector<PlanPlacement> placements;
+69 -28
View File
@@ -80,11 +80,20 @@ const char *LayoutLibrary::defaultTemplateSource()
* district preset (and edge-side overrides) flow in through the
* min/max bindings, so footprint/floors still come from the
* merged district params and the rand draws keep the legacy
* TownGen draw order (width, depth, floors). */
* TownGen draw order (width, depth, floors).
*
* Floor 0 declares a living room and a bedroom (phase 4.7.3 bed
* invariant: every default home has a bedroom for the interior
* generator to place beds in). Rooms/idoor draw no RNG, and in
* exteriorOnly mode internal walls/doors produce no geometry, so
* the town exterior is unchanged. */
return "(shell (rand-range min-fp max-fp)\n"
" (rand-range min-fp max-fp)\n"
" :floors (rand-int min-floors max-floors))\n"
"(floor 0 (door front center))\n"
"(floor 0 (room living (rect 0 0 0.6 1) :type living)\n"
" (room bedroom (rect 0.6 0 1 1) :type bedroom)\n"
" (idoor living bedroom)\n"
" (door front center))\n"
"(windows all)\n";
}
@@ -98,8 +107,8 @@ const std::vector<LayoutLibrary::LayoutExample> &LayoutLibrary::examples()
"(roof gable :height 2)\n" },
{ "Two rooms with an interior door",
"(shell 12 8)\n"
"(floor 0 (room living (rect 0 0 0.5 1))\n"
" (room kitchen (rect 0.5 0 1 1))\n"
"(floor 0 (room living (rect 0 0 0.5 1) :type living)\n"
" (room kitchen (rect 0.5 0 1 1) :type kitchen)\n"
" (idoor living kitchen)\n"
" (door front center)\n"
" (windows all))\n" },
@@ -1117,25 +1126,34 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
return fail(form, "room options come in :keyword value "
"pairs");
bool noWindows = false;
std::string roomType;
for (size_t i = 3; i < form.list.size(); i += 2) {
if (!form.list[i].isSymbol())
return fail(form.list[i], "room option must be a "
":keyword");
const std::string &key = form.list[i].text;
if (key != ":windows")
if (key == ":windows") {
if (!form.list[i + 1].isSymbol())
return fail(form.list[i + 1],
":windows needs none or all");
const std::string &val = form.list[i + 1].text;
if (val == "none")
noWindows = true;
else if (val == "all" || val == "default")
noWindows = false;
else
return fail(form.list[i + 1],
":windows must be none or all");
} else if (key == ":type") {
if (!form.list[i + 1].isSymbol())
return fail(form.list[i + 1],
":type needs a room type name");
roomType = form.list[i + 1].text;
} else {
return fail(form.list[i], "unknown room option '" +
key + "' (only :windows)");
if (!form.list[i + 1].isSymbol())
return fail(form.list[i + 1], ":windows needs "
"none or all");
const std::string &val = form.list[i + 1].text;
if (val == "none")
noWindows = true;
else if (val == "all" || val == "default")
noWindows = false;
else
return fail(form.list[i + 1], ":windows must be "
"none or all");
key +
"' (only :windows, :type)");
}
}
const SExpr &rect = form.list[2];
if (!rect.isList() || rect.list.size() != 5 ||
@@ -1147,6 +1165,20 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
!argNumber(rect.list[3], x1) || !argNumber(rect.list[4], z1))
return false;
/* Register the room name (+ optional :type) into the plan's
* room tables; re-declaring a name (e.g. on another floor)
* reuses its id and its first type wins. */
auto registerRoom = [this, &form, &roomType]() -> int {
auto nit = m_roomNameIds.find(form.list[1].text);
if (nit != m_roomNameIds.end())
return nit->second;
int id = (int)m_plan->roomNames.size();
m_plan->roomNames.push_back(form.list[1].text);
m_plan->roomTypes.push_back(roomType);
m_roomNameIds.emplace(form.list[1].text, id);
return id;
};
if (m_emptyShell) {
/* Fractions must stay inside the shell rect — no
* silent clamping in rooms-first mode. */
@@ -1169,13 +1201,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
return fail(rect, "room '" + form.list[1].text +
"' escapes the shell rect");
auto nit = m_roomNameIds.find(form.list[1].text);
if (nit == m_roomNameIds.end()) {
int id = (int)m_plan->roomNames.size();
m_plan->roomNames.push_back(form.list[1].text);
nit = m_roomNameIds.emplace(form.list[1].text, id).first;
}
const int roomId = nit->second;
const int roomId = registerRoom();
rr.noWindows = noWindows;
m_rooms[m_floor][form.list[1].text] = rr;
@@ -1218,19 +1244,32 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
if (x1 <= x0 || z1 <= z0)
return fail(rect, "room rect is empty");
/* Same half-open snapping as rooms-first mode (rect edges at
* lround(frac * cells), room covers [x0, x1)): neighboring rooms
* sharing a fraction always meet at the same boundary index, so
* idoor works for any shell size. */
RoomRect rr;
rr.x0 = std::max(0, (int)std::floor(x0 * m_plan->cellsX));
rr.z0 = std::max(0, (int)std::floor(z0 * m_plan->cellsZ));
rr.x0 = std::max(0, (int)std::lround(x0 * m_plan->cellsX));
rr.z0 = std::max(0, (int)std::lround(z0 * m_plan->cellsZ));
rr.x1 = std::min(m_plan->cellsX - 1,
(int)std::ceil(x1 * m_plan->cellsX) - 1);
(int)std::lround(x1 * m_plan->cellsX) - 1);
rr.z1 = std::min(m_plan->cellsZ - 1,
(int)std::ceil(z1 * m_plan->cellsZ) - 1);
(int)std::lround(z1 * m_plan->cellsZ) - 1);
if (rr.x1 < rr.x0 || rr.z1 < rr.z0)
return fail(rect, "room rect maps to no cells");
rr.noWindows = noWindows;
m_rooms[m_floor][form.list[1].text] = rr;
/* Metadata only in filled-shell mode: record the room cells so
* consumers (location/interior generators) can read room
* names/types for legacy layouts too. No geometry effect. */
const int roomId = registerRoom();
if (m_plan->roomIds.empty())
m_plan->roomIds.assign((size_t)m_plan->cellsX *
m_plan->cellsZ * m_plan->floors,
-1);
for (int z = rr.z0; z <= rr.z1; ++z) {
for (int x = rr.x0; x <= rr.x1; ++x) {
uint64_t &c = m_plan->at(x, m_floor, z);
@@ -1242,6 +1281,8 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
c |= PlanFlags::IntWallZNeg;
if (z == rr.z1 && rr.z1 < m_plan->cellsZ - 1)
c |= PlanFlags::IntWallZPos;
m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) *
m_plan->cellsX + x] = roomId;
}
}
return true;
+33
View File
@@ -8,6 +8,7 @@
#include "systems/TerrainTests.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/TownGenSystem.hpp"
#include "systems/NpcPopulationSystem.hpp"
#include "roadlib/TownGen.hpp"
#include "OgreRoot.h"
@@ -41,6 +42,11 @@ struct GenerateTownFrameListener : public Ogre::FrameListener {
int regenSide = -1;
int regenWait = 0;
bool useAreas = false;
/* --generate-town-population: after town generation, generate the
* deterministic per-area NPC populations into the registry. */
bool population = false;
bool populationDone = false;
EditorApp *app = nullptr;
/* TOWNGEN_REPEAT_GENERATE=1: run the generation a second time a
* few frames after the first — the headless equivalent of
* pressing "Generate All Areas" twice (the settlement material
@@ -91,6 +97,7 @@ struct GenerateTownFrameListener : public Ogre::FrameListener {
result = -4;
return true;
}
runPopulation();
result = 0;
root->queueEndRendering();
return true;
@@ -117,6 +124,7 @@ struct GenerateTownFrameListener : public Ogre::FrameListener {
result = -4;
return true;
}
runPopulation();
result = 0;
root->queueEndRendering();
return true;
@@ -130,6 +138,21 @@ struct GenerateTownFrameListener : public Ogre::FrameListener {
}
return true;
}
void runPopulation()
{
if (!population || populationDone)
return;
populationDone = true;
NpcPopulationSystem *popSys =
app ? app->getNpcPopulationSystem() : nullptr;
if (!popSys)
return;
std::string error;
int n = popSys->generateAllAreas(&error);
std::cout << "generate-town-population: " << n << " NPCs"
<< (error.empty() ? "" : " (" + error + ")")
<< std::endl;
}
};
/** Frame-driven terrain test runner.
@@ -181,6 +204,7 @@ int main(int argc, char *argv[])
int genTownRegenEdge = -1;
int genTownRegenSide = 0;
bool genTownUseAreas = false;
bool genTownPopulation = false;
Ogre::String sceneFile;
Ogre::String projectDir;
for (int i = 1; i < argc; i++) {
@@ -225,6 +249,13 @@ int main(int argc, char *argv[])
/* Mirrors the "Generate All Areas" button. */
generateTown = true;
genTownUseAreas = true;
} else if (arg == "--generate-town-population") {
/* Implies --generate-town-areas; afterwards
* generate the per-area NPC populations into the
* CharacterRegistry (phase 4.7.2). */
generateTown = true;
genTownUseAreas = true;
genTownPopulation = true;
} else if (arg.find("--regen-lot-params=") == 0) {
/* --regen-lot-params=<edge>,<side>: after
* --generate-town, regenerate the buildings of
@@ -350,6 +381,8 @@ int main(int argc, char *argv[])
genTownListener.regenEdge = genTownRegenEdge;
genTownListener.regenSide = genTownRegenSide;
genTownListener.useAreas = genTownUseAreas;
genTownListener.population = genTownPopulation;
genTownListener.app = &app;
if (generateTown)
app.getRoot()->addFrameListener(&genTownListener);
app.getRoot()->startRendering();
@@ -705,6 +705,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
TownPoi poi;
poi.name = b.address + " pickup";
poi.kind = TownPoi::Pickup;
poi.position = center2d - v * (depth * 0.5f + 3.0f);
poi.position.y =
sampleY(sampleHeight, poi.position.x,
@@ -724,6 +725,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
built.plan.parkings[pi];
TownPoi ppPoi;
ppPoi.name = built.address + " parking";
ppPoi.kind = TownPoi::Parking;
if (built.plan.parkings.size() > 1)
ppPoi.name += " " + std::to_string(pi + 1);
ppPoi.position = frontMid +
@@ -119,9 +119,16 @@ struct TownBuilding {
/** Named curb point in front of a building's door (pickup/dropoff,
* pedestrian origin/destination). */
struct TownPoi {
/** POI role (phase 4.7.3): a curb pickup/dropoff point or a
* parking pad. Replaces name-suffix parsing. */
enum Kind {
Pickup = 0,
Parking
};
std::string name; /* "<address> pickup" */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
int building = -1; /* index into buildings */
Kind kind = Pickup;
};
struct TownResult {
@@ -0,0 +1,164 @@
#include "TownLocations.hpp"
namespace TownLocations {
static uint32_t fnv1a(const std::string &s)
{
uint32_t h = 2166136261u;
for (char c : s) {
h ^= (uint8_t)c;
h *= 16777619u;
}
return h;
}
const char *locationKindName(LocationKind kind)
{
switch (kind) {
case LocationKind::Home: return "home";
case LocationKind::Work: return "work";
case LocationKind::Leisure: return "leisure";
case LocationKind::Interest: return "interest";
case LocationKind::Curb: return "curb";
case LocationKind::Parking: return "parking";
default: return "?";
}
}
const NpcLocation *LocationSet::findByName(const std::string &name) const
{
for (const NpcLocation &loc : locations)
if (loc.name == name)
return &loc;
return nullptr;
}
int LocationSet::homeOfBuilding(int building) const
{
for (const NpcLocation &loc : locations)
if (loc.kind == LocationKind::Home && loc.building == building)
return loc.id;
return -1;
}
std::vector<int> LocationSet::indicesOfKind(LocationKind kind) const
{
std::vector<int> out;
for (const NpcLocation &loc : locations)
if (loc.kind == kind)
out.push_back(loc.id);
return out;
}
std::string pickLocationName(const LocationSet &locs,
const std::vector<int> &candidates,
const std::string &stableKey,
const char *purpose,
const std::string &fallback)
{
if (candidates.empty())
return fallback;
/* Rendezvous (highest-random-weight) hashing: the pick is the
* candidate maximizing hash(stableKey, purpose, candidate), so
* a candidate appearing or vanishing rebinds only the NPCs that
* referenced it — everyone else keeps their location. */
uint32_t h0 = fnv1a(stableKey) ^ fnv1a(purpose);
int best = -1;
uint32_t bestH = 0;
for (int c : candidates) {
uint32_t h = (h0 ^ fnv1a(locs.locations[c].name)) *
16777619u;
if (best < 0 || h > bestH) {
best = c;
bestH = h;
}
}
return locs.locations[best].name;
}
NpcBindings computeBindings(const LocationSet &locs,
const std::string &stableKey,
const std::string &homeAddress)
{
NpcBindings b;
b.home = "home:" + homeAddress;
const std::vector<int> interest =
locs.indicesOfKind(LocationKind::Interest);
std::vector<int> visit = interest;
{
std::vector<int> curbs =
locs.indicesOfKind(LocationKind::Curb);
visit.insert(visit.end(), curbs.begin(), curbs.end());
}
b.work = pickLocationName(locs, interest, stableKey, "work", b.home);
b.interest =
pickLocationName(locs, visit, stableKey, "interest", b.home);
return b;
}
LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
float interestRatio)
{
LocationSet set;
/* Building index -> its pickup POI (interest locations sit at
* the curb so pathing to them needs no building entry). */
std::vector<int> pickupOf(town.buildings.size(), -1);
for (size_t i = 0; i < town.pois.size(); ++i) {
const TownGen::TownPoi &poi = town.pois[i];
if (poi.kind == TownGen::TownPoi::Pickup && poi.building >= 0 &&
(size_t)poi.building < pickupOf.size() &&
pickupOf[poi.building] < 0)
pickupOf[poi.building] = (int)i;
}
auto add = [&set](NpcLocation &loc) {
loc.id = (int)set.locations.size();
set.locations.push_back(loc);
};
for (size_t bi = 0; bi < town.buildings.size(); ++bi) {
const TownGen::TownBuilding &b = town.buildings[bi];
NpcLocation home;
home.kind = LocationKind::Home;
home.name = std::string("home:") + b.address;
home.address = b.address;
home.position = b.center;
home.building = (int)bi;
add(home);
/* Deterministic per-building interest roll — no shared
* stream, so one lot's edit never shifts another lot's
* locations. */
uint32_t h = fnv1a(b.address) ^ (seed * 16777619u);
float roll = (h % 1000) / 1000.0f;
if (roll < interestRatio) {
NpcLocation interest;
interest.kind = LocationKind::Interest;
interest.name = std::string("interest:") + b.address;
interest.address = b.address;
interest.position = pickupOf[bi] >= 0 ?
town.pois[pickupOf[bi]].position : b.center;
interest.building = (int)bi;
add(interest);
}
}
for (const TownGen::TownPoi &poi : town.pois) {
NpcLocation loc;
loc.kind = poi.kind == TownGen::TownPoi::Parking ?
LocationKind::Parking : LocationKind::Curb;
loc.name = poi.name;
loc.building = poi.building;
if (poi.building >= 0 &&
(size_t)poi.building < town.buildings.size())
loc.address = town.buildings[poi.building].address;
loc.position = poi.position;
add(loc);
}
return set;
}
} /* namespace TownLocations */
@@ -0,0 +1,105 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "TownGen.hpp"
/**
* Symbolic location model (demo-taxi phase 4.7.3).
*
* Pure functions of a TownGen::TownResult plus a seed — no ECS, fully
* exercisable headlessly. Locations are the named places NPCs bind
* to and travel between: home (a building), curb (a pickup POI),
* parking (a parking POI) and interest (a deterministic subset of
* buildings doubling as shops/landmarks/workplaces until zoning
* exists). Authored locations (the NpcLocationComponent marker) are
* merged in by the ECS side, not here.
*
* Determinism (the TownGen per-lot discipline): there is no shared
* RNG stream — every per-building roll is a hash of
* `seed ^ fnv1a(address)`, so editing one lot can never shift another
* lot's locations.
*/
namespace TownLocations {
enum class LocationKind {
Home = 0,
Work, /* reserved: no work zoning yet; bindings pick
* from Interest instead */
Leisure, /* reserved for authored markers */
Interest,
Curb,
Parking,
COUNT
};
const char *locationKindName(LocationKind kind);
struct NpcLocation {
/** Index into LocationSet::locations. */
int id = -1;
LocationKind kind = LocationKind::Home;
/** Unique within the set: "<kind>:<address>" (POI locations
* reuse the POI name). */
std::string name;
/** Owning building's address ("" for standalone markers). */
std::string address;
/** Render/world XZ of the caller's coordinate space. */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
/** Index into TownResult::buildings, -1 when standalone. */
int building = -1;
};
struct LocationSet {
std::vector<NpcLocation> locations;
/** Null when not found. */
const NpcLocation *findByName(const std::string &name) const;
/** Home location index of a building, -1 when none. */
int homeOfBuilding(int building) const;
/** Indices of all locations of a kind, in location order. */
std::vector<int> indicesOfKind(LocationKind kind) const;
};
/**
* Per-NPC location bindings (phase 4.7.4): the deterministic picks the
* population system stores in the registry stringColumns and the
* schedule generator consumes. `home` is "home:" + homeAddress;
* `work` picks from the Interest locations (the workplace placeholder
* until zoning exists), `interest` from Interest+Curb; both fall back
* to `home` when the set has no candidates. The pick is a hash of
* (stableKey, purpose), so a regeneration with the same town re-picks
* the same bindings.
*/
struct NpcBindings {
std::string home;
std::string work;
std::string interest;
};
/** Deterministic pick from candidate location indices: rendezvous
* (highest-random-weight) hashing, so a candidate appearing or
* vanishing rebinds only the NPCs that referenced it. */
std::string pickLocationName(const LocationSet &locs,
const std::vector<int> &candidates,
const std::string &stableKey,
const char *purpose,
const std::string &fallback);
NpcBindings computeBindings(const LocationSet &locs,
const std::string &stableKey,
const std::string &homeAddress);
/**
* Build the location set of a town: one Home per building (position
* = building center at floor level), one Curb per pickup POI, one
* Parking per parking POI, and an Interest location for every
* building whose hash roll passes `interestRatio` (position = its
* pickup POI when one exists, else the building center).
*/
LocationSet buildLocations(const TownGen::TownResult &town,
uint32_t seed, float interestRatio = 0.15f);
} /* namespace TownLocations */
@@ -0,0 +1,206 @@
#include "TownPopulation.hpp"
#include <random>
namespace TownPopulation {
/* ------------------------------------------------------------------ */
/* Built-in default name pools (used when a rules pool is empty) */
/* ------------------------------------------------------------------ */
static const char *kMaleFirst[] = {
"James", "John", "Robert", "Michael", "William", "David",
"Richard", "Joseph", "Thomas", "Charles", "Daniel", "Matthew",
"Anthony", "Mark", "Paul", "Steven", "Andrew", "Kenneth",
"George", "Joshua", "Kevin", "Brian", "Edward", "Ronald",
};
static const char *kFemaleFirst[] = {
"Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara",
"Susan", "Jessica", "Sarah", "Karen", "Nancy", "Lisa",
"Margaret", "Betty", "Sandra", "Ashley", "Dorothy", "Kimberly",
"Emily", "Michelle", "Amanda", "Melissa", "Deborah", "Laura",
};
static const char *kLastNames[] = {
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia",
"Miller", "Davis", "Wilson", "Anderson", "Taylor", "Thomas",
"Moore", "Martin", "Jackson", "Thompson", "White", "Harris",
"Clark", "Lewis", "Walker", "Hall", "Young", "King",
};
static std::vector<std::string> toVec(const char *const *arr, size_t n)
{
std::vector<std::string> v;
v.reserve(n);
for (size_t i = 0; i < n; ++i)
v.push_back(arr[i]);
return v;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static uint32_t fnv1a(const std::string &s)
{
uint32_t h = 2166136261u;
for (char c : s) {
h ^= (uint8_t)c;
h *= 16777619u;
}
return h;
}
static int randInt(std::mt19937 &rng, int lo, int hi)
{
if (hi <= lo)
return lo;
std::uniform_int_distribution<int> d(lo, hi);
return d(rng);
}
static const std::string &pick(const std::vector<std::string> &pool,
std::mt19937 &rng)
{
return pool[(size_t)randInt(rng, 0, (int)pool.size() - 1)];
}
/* ------------------------------------------------------------------ */
/* Generation */
/* ------------------------------------------------------------------ */
Population generatePopulation(const TownGen::TownResult &town,
uint32_t popSeed,
const PopulationRules &rules,
uint64_t idBase)
{
Population pop;
std::vector<std::string> maleFirst = rules.maleFirstNames.empty() ?
toVec(kMaleFirst,
sizeof(kMaleFirst) / sizeof(kMaleFirst[0])) :
rules.maleFirstNames;
std::vector<std::string> femaleFirst =
rules.femaleFirstNames.empty() ?
toVec(kFemaleFirst,
sizeof(kFemaleFirst) / sizeof(kFemaleFirst[0])) :
rules.femaleFirstNames;
std::vector<std::string> lastNames = rules.lastNames.empty() ?
toVec(kLastNames, sizeof(kLastNames) / sizeof(kLastNames[0])) :
rules.lastNames;
std::mt19937 rng;
std::uniform_real_distribution<float> unit(0.0f, 1.0f);
for (size_t bi = 0; bi < town.buildings.size(); ++bi) {
const TownGen::TownBuilding &b = town.buildings[bi];
/* Per-building stream: one lot's household can never
* shift another lot's draws. */
rng.seed(popSeed ^ fnv1a(b.address));
Household hh;
hh.building = (int)bi;
hh.address = b.address;
hh.lastName = pick(lastNames, rng);
int size = randInt(rng, rules.minHouseholdSize,
rules.maxHouseholdSize);
/* Keep the per-building id band from overflowing into the
* next building's band. */
if (size > (int)HOUSEHOLD_ID_CAP - 1)
size = (int)HOUSEHOLD_ID_CAP - 1;
bool couple = size > 1 && unit(rng) < rules.coupleProbability;
int adults = couple ? 2 : 1;
int children = size - adults;
/* Fixed draw order per member: sex, elder roll (adults
* only), ageYears, outfitLevel, firstName. */
auto addMember = [&](bool child) -> int {
PopMember m;
m.household = (int)pop.households.size();
m.sex = unit(rng) < 0.5f ? "male" : "female";
if (child) {
m.age = "child";
m.ageYears = randInt(rng, rules.childMinYears,
rules.childMaxYears);
} else {
bool elder = unit(rng) < rules.elderProbability;
m.age = elder ? "elder" : "adult";
m.ageYears = elder ?
randInt(rng, rules.elderMinYears,
rules.elderMaxYears) :
randInt(rng, rules.adultMinYears,
rules.adultMaxYears);
}
m.outfitLevel = randInt(rng, rules.minOutfitLevel,
rules.maxOutfitLevel);
const std::vector<std::string> &pool =
m.sex == "male" ? maleFirst : femaleFirst;
m.firstName = pick(pool, rng);
/* Re-draw first-name collisions within the
* household (bounded, then accept). */
for (int tries = 0; tries < 4; ++tries) {
bool clash = false;
for (int oi : hh.members) {
if (pop.members[oi].firstName ==
m.firstName) {
clash = true;
break;
}
}
if (!clash)
break;
m.firstName = pick(pool, rng);
}
m.lastName = hh.lastName;
m.stableKey = b.address + "#" +
std::to_string(hh.members.size());
m.registryId = idBase +
(uint64_t)bi * HOUSEHOLD_ID_CAP +
(uint64_t)hh.members.size();
hh.members.push_back((int)pop.members.size());
pop.members.push_back(m);
return (int)pop.members.size() - 1;
};
std::vector<int> adultIdx, childIdx;
for (int i = 0; i < adults; ++i)
adultIdx.push_back(addMember(false));
for (int i = 0; i < children; ++i)
childIdx.push_back(addMember(true));
/* Relationship seeds (directed; tag on A -> B states
* what A is to B). */
auto rel = [&](int from, int to, const char *tag) {
PopRelationship r;
r.from = from;
r.to = to;
r.tag = tag;
hh.relationships.push_back(r);
};
if (adultIdx.size() == 2) {
rel(0, 1, "spouse");
rel(1, 0, "spouse");
}
for (size_t ci = 0; ci < childIdx.size(); ++ci) {
int cl = adults + (int)ci;
for (size_t ai = 0; ai < adultIdx.size(); ++ai) {
rel(cl, (int)ai, "child");
rel((int)ai, cl, "parent");
}
for (size_t si = 0; si < ci; ++si) {
int sl = adults + (int)si;
rel(cl, sl, "sibling");
rel(sl, cl, "sibling");
}
}
pop.households.push_back(hh);
}
return pop;
}
} /* namespace TownPopulation */
@@ -0,0 +1,136 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "TownGen.hpp"
/**
* Town population generation (demo-taxi phase 4.7.2).
*
* Pure functions of a TownGen::TownResult plus PopulationRules and a
* seed — no ECS, no registry dependency, fully exercisable
* headlessly. The ECS-side population system applies the result to
* the CharacterRegistry as runtime-only entries.
*
* Determinism (the TownGen per-lot discipline): every building's
* household draws from its OWN mt19937 stream seeded
* `popSeed ^ fnv1a(address)`, so editing one lot can never shift
* another lot's household. Registry IDs come from the building's
* own HOUSEHOLD_ID_CAP-sized band, so a household-size change on one
* lot never shifts another lot's ids either. The caller derives
* idBase as NPC_ID_BASE + areaIndex * AREA_POP_CAP, keeping areas
* collision-free without any hashing. Household sizes are clamped
* to HOUSEHOLD_ID_CAP - 1.
*/
namespace TownPopulation {
/**
* Population NPCs live in the registry's runtime id space
* (>= 1 << 32) but far above the sequential runtime allocator's
* reach: idBase for area i is NPC_ID_BASE + i * AREA_POP_CAP.
* Within an area each BUILDING gets a HOUSEHOLD_ID_CAP-sized id
* band (idBase + buildingIndex * HOUSEHOLD_ID_CAP + memberIndex), so
* a household-size change on one lot never shifts another lot's
* ids.
*/
static const uint64_t NPC_ID_BASE = 1ull << 40;
static const uint64_t AREA_POP_CAP = 1ull << 20;
static const uint64_t HOUSEHOLD_ID_CAP = 64;
/** Per-area population generation rules (live with the town area). */
struct PopulationRules {
/** Household size range (members per residential building). */
int minHouseholdSize = 1;
int maxHouseholdSize = 5;
/** Chance a household with room for two has two adults. */
float coupleProbability = 0.7f;
/** Chance an adult is an elder. */
float elderProbability = 0.2f;
/** Age-in-years ranges per band. */
int childMinYears = 1;
int childMaxYears = 17;
int adultMinYears = 18;
int adultMaxYears = 64;
int elderMinYears = 65;
int elderMaxYears = 90;
/** Outfit level range (CharacterRecord::inlineOutfitLevel). */
int minOutfitLevel = 0;
int maxOutfitLevel = 2;
/** Name pools; an empty pool falls back to the built-in
* defaults. The ECS side can feed Markov-trained pools from
* the CharacterRegistry instead. */
std::vector<std::string> maleFirstNames;
std::vector<std::string> femaleFirstNames;
std::vector<std::string> lastNames;
};
/** One generated NPC (attribute-driven; the look system consumes
* sex/age/outfitLevel — more attributes slot in here as they are
* added). */
struct PopMember {
/** Deterministic runtime-registry id: idBase + buildingIndex *
* HOUSEHOLD_ID_CAP + household-local member index. */
uint64_t registryId = 0;
/** "<address>#<memberIndex>" — stable across regenerations of
* the same town. */
std::string stableKey;
/** Index into Population::households. */
int household = -1;
std::string firstName;
std::string lastName;
/** "male" | "female" (CharacterRecord::inlineSex). */
std::string sex;
/** "child" | "adult" | "elder" (CharacterRecord::age). */
std::string age;
int ageYears = 0;
int outfitLevel = 0;
};
/**
* A directed household relationship seed. Tag convention: the tag
* on A -> B states what A is to B ("child" on child -> parent,
* "parent" on parent -> child, "spouse"/"sibling" both directions).
* Households seed spouse/sibling/child/parent only; acquainted,
* friend and beyond are left to later systems.
*/
struct PopRelationship {
/** Household-local member indices (into Household::members). */
int from = -1;
int to = -1;
std::string tag;
};
struct Household {
/** Index into TownResult::buildings (the home). */
int building = -1;
std::string address;
/** Shared family name. */
std::string lastName;
/** Indices into Population::members; adults first. */
std::vector<int> members;
std::vector<PopRelationship> relationships;
};
struct Population {
std::vector<PopMember> members;
std::vector<Household> households;
};
/**
* Generate one household per building of the town. Draw order per
* building (fixed, determinism-critical): family last name,
* household size, couple roll, then per adult (sex, elder roll,
* ageYears, outfitLevel, firstName), then per child (sex, ageYears,
* outfitLevel, firstName). First names re-draw on collision within
* the household. Household size is clamped to HOUSEHOLD_ID_CAP - 1
* so the per-building id band cannot overflow into the next
* building's band.
*/
Population generatePopulation(const TownGen::TownResult &town,
uint32_t popSeed,
const PopulationRules &rules,
uint64_t idBase = NPC_ID_BASE);
} /* namespace TownPopulation */
@@ -0,0 +1,134 @@
#include "TownSchedule.hpp"
#include <algorithm>
#include <cmath>
#include <random>
namespace TownSchedule {
static uint32_t fnv1a(const std::string &s)
{
uint32_t h = 2166136261u;
for (char c : s) {
h ^= (uint8_t)c;
h *= 16777619u;
}
return h;
}
static float randRange(std::mt19937 &rng, float lo, float hi)
{
std::uniform_real_distribution<float> d(lo, hi);
return d(rng);
}
static float clampf(float v, float lo, float hi)
{
return std::max(lo, std::min(hi, v));
}
std::vector<NpcSchedule> generateSchedules(
const TownPopulation::Population &pop,
const TownLocations::LocationSet &locations, uint32_t seed,
const ScheduleRules &rules)
{
std::vector<NpcSchedule> out;
out.reserve(pop.members.size());
for (const TownPopulation::PopMember &pm : pop.members) {
std::mt19937 rng(seed ^ fnv1a(pm.stableKey));
const std::string &homeAddr =
pop.households[pm.household].address;
TownLocations::NpcBindings bind =
TownLocations::computeBindings(locations, pm.stableKey,
homeAddr);
/* Fixed draw order: wake jitter, (adults: work start
* jitter), errand roll, errand length jitter. */
float wake = clampf(rules.wakeHour +
randRange(rng, -rules.wakeSpread,
rules.wakeSpread),
4.0f, 10.0f);
const bool adult = pm.age == "adult";
float workStart = 0.0f;
if (adult)
workStart = clampf(rules.workStartHour +
randRange(rng,
-rules.workStartSpread,
rules.workStartSpread),
wake + 0.5f, 12.0f);
bool errand = randRange(rng, 0.0f, 1.0f) <
rules.errandProbability;
float errandLen = std::max(
0.5f, rules.errandHours +
randRange(rng, -0.5f, 0.5f));
NpcSchedule sched;
sched.registryId = pm.registryId;
sched.stableKey = pm.stableKey;
/* Build the day with a monotonic cursor so the entries
* always tile [0, 24) in order; degenerate segments are
* skipped. */
float t = 0.0f;
auto push = [&t](NpcSchedule &s, float end,
const std::string &loc, const char *act) {
end = clampf(end, t, 24.0f);
if (end > t + 1.0e-4f) {
ScheduleEntry e;
e.startHour = t;
e.endHour = end;
e.location = loc;
e.activity = act;
s.entries.push_back(e);
t = end;
}
};
push(sched, wake, bind.home, "sleep");
if (adult) {
float workEnd = workStart + rules.workHours;
push(sched, workStart, bind.home, "home");
push(sched, workEnd, bind.work, "work");
if (errand) {
push(sched, workEnd + errandLen,
bind.interest, "errand");
}
} else {
float outHour = pm.age == "child" ?
rules.childOutHour : rules.elderOutHour;
push(sched, outHour, bind.home, "home");
if (errand)
push(sched, outHour + errandLen,
bind.interest, "errand");
}
push(sched, rules.sleepHour, bind.home, "home");
push(sched, 24.0f, bind.home, "sleep");
if (sched.entries.empty()) {
/* Pathological rules — guarantee coverage. */
ScheduleEntry e;
e.startHour = 0.0f;
e.endHour = 24.0f;
e.location = bind.home;
e.activity = "home";
sched.entries.push_back(e);
}
out.push_back(std::move(sched));
}
return out;
}
const ScheduleEntry *entryAt(const NpcSchedule &schedule,
float timeOfDayHours)
{
if (schedule.entries.empty())
return nullptr;
float t = timeOfDayHours - 24.0f * std::floor(timeOfDayHours / 24.0f);
for (const ScheduleEntry &e : schedule.entries)
if (t >= e.startHour && t < e.endHour)
return &e;
return &schedule.entries.back();
}
} /* namespace TownSchedule */
@@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "TownLocations.hpp"
#include "TownPopulation.hpp"
/**
* NPC time-of-day schedules (demo-taxi phase 4.7.4).
*
* Pure functions of the population, the location set, rules and a
* seed — no ECS, fully exercisable headlessly. Every NPC gets a
* schedule that tiles [0, 24) with (location, activity) entries; the
* motion core (4.7.5) turns the active entry into a goal.
*
* Determinism (the TownGen per-lot discipline): every NPC draws from
* its OWN mt19937 stream seeded `seed ^ fnv1a(stableKey)`, so editing
* one lot can never shift another lot's schedules. Scripts force a
* regeneration by re-running the population generation — same town
* state, same schedules.
*/
namespace TownSchedule {
/** Per-town schedule generation rules (live with the town area). */
struct ScheduleRules {
/** Wake time ~ wakeHour +/- wakeSpread (clamped to [4, 10]). */
float wakeHour = 7.0f;
float wakeSpread = 1.0f;
/** Work start ~ workStartHour +/- workStartSpread (adults). */
float workStartHour = 9.0f;
float workStartSpread = 0.5f;
/** Work block length in hours (adults). */
float workHours = 8.0f;
/** Chance of a daily interest/errand visit. */
float errandProbability = 0.6f;
/** Errand block length in hours. */
float errandHours = 1.5f;
/** Interest-visit start for children / elders. */
float childOutHour = 15.0f;
float elderOutHour = 10.0f;
/** Evening return to sleep. */
float sleepHour = 22.0f;
};
struct ScheduleEntry {
float startHour = 0.0f; /* [0, 24) */
float endHour = 0.0f; /* (startHour, 24] */
/** LocationSet location name (TownLocations::NpcBindings). */
std::string location;
/** "sleep" | "home" | "work" | "errand". */
std::string activity;
};
struct NpcSchedule {
uint64_t registryId = 0;
std::string stableKey;
/** Sorted by startHour, tiling [0, 24) without gaps. */
std::vector<ScheduleEntry> entries;
};
/**
* Generate one schedule per population member. Per-NPC draw order
* (fixed, determinism-critical): wake jitter, then band-specific
* draws (adults: work start jitter; all: errand roll, errand length
* jitter). Work/errand locations come from
* TownLocations::computeBindings.
*/
std::vector<NpcSchedule> generateSchedules(
const TownPopulation::Population &pop,
const TownLocations::LocationSet &locations, uint32_t seed,
const ScheduleRules &rules);
/** The active entry at a time of day in hours [0, 24); nullptr only
* for an empty schedule. */
const ScheduleEntry *entryAt(const NpcSchedule &schedule,
float timeOfDayHours);
} /* namespace TownSchedule */
@@ -57,6 +57,8 @@ void CharacterRegistry::initialize()
void CharacterRegistry::autoSave()
{
if (m_autoSaveSuspended)
return;
if (!m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
@@ -942,6 +944,27 @@ uint64_t CharacterRegistry::createCharacter(const std::string &firstName,
return id;
}
uint64_t CharacterRegistry::ensureRuntimeCharacter(
uint64_t id, const std::string &firstName, const std::string &lastName)
{
/* Refuse ids inside the sequential allocators' ranges: persistent
* ids below 1<<32, sequential runtime ids below the population
* band base (1<<40). */
if (id < (1ull << 40))
return 0;
auto it = m_characters.find(id);
if (it != m_characters.end())
return id;
CharacterRecord rec;
rec.id = id;
rec.firstName = firstName;
rec.lastName = lastName;
rec.persistent = false;
m_characters[id] = rec;
return id;
}
void CharacterRegistry::deleteCharacter(uint64_t id)
{
auto it = m_characters.find(id);
@@ -971,14 +994,34 @@ CharacterRegistry::findCharacter(uint64_t id) const
/* Groups */
/* ===================================================================== */
uint64_t CharacterRegistry::createGroup(const std::string &name)
uint64_t CharacterRegistry::createGroup(const std::string &name,
bool persistent)
{
uint64_t id = m_nextId++;
uint64_t id = persistent ? m_nextId++ : m_nextRuntimeId++;
GroupRecord rec;
rec.id = id;
rec.name = name;
rec.persistent = persistent;
m_groups[id] = rec;
if (persistent)
autoSave();
return id;
}
uint64_t CharacterRegistry::ensureRuntimeGroup(uint64_t id,
const std::string &name)
{
if (id < (1ull << 40))
return 0;
auto it = m_groups.find(id);
if (it != m_groups.end())
return id;
GroupRecord rec;
rec.id = id;
rec.name = name;
rec.persistent = false;
m_groups[id] = rec;
autoSave();
return id;
}
@@ -1346,9 +1389,12 @@ nlohmann::json CharacterRegistry::serialize() const
for (const auto &pair : m_groups) {
const GroupRecord &g = pair.second;
if (!g.persistent)
continue;
nlohmann::json rec;
rec["id"] = g.id;
rec["name"] = g.name;
rec["persistent"] = g.persistent;
rec["memberIds"] = g.memberIds;
for (const auto &kv : g.intColumns)
rec["intColumns"][kv.first] = kv.second;
@@ -1360,6 +1406,22 @@ nlohmann::json CharacterRegistry::serialize() const
}
for (const auto &r : m_relationships) {
/* Relationships touching runtime-only or dangling
* endpoints are runtime data themselves — never
* persisted. */
auto endpointPersistent = [this](uint64_t id, bool isGroup) {
if (isGroup) {
auto it = m_groups.find(id);
return it != m_groups.end() &&
it->second.persistent;
}
auto it = m_characters.find(id);
return it != m_characters.end() &&
it->second.persistent;
};
if (!endpointPersistent(r.sourceId, r.sourceIsGroup) ||
!endpointPersistent(r.targetId, r.targetIsGroup))
continue;
nlohmann::json rec;
rec["sourceId"] = r.sourceId;
rec["sourceIsGroup"] = r.sourceIsGroup;
@@ -1559,6 +1621,7 @@ void CharacterRegistry::deserialize(const nlohmann::json &j)
GroupRecord g;
g.id = rec.value("id", 0);
g.name = rec.value("name", "");
g.persistent = rec.value("persistent", true);
if (rec.contains("memberIds"))
for (const auto &m : rec["memberIds"])
g.memberIds.push_back(
@@ -110,6 +110,11 @@ public:
std::string name;
std::vector<uint64_t> memberIds;
/* Runtime-only groups are NOT saved to
* character_registry.json (generated households, phase
* 4.7.2). */
bool persistent = true;
/* Extensible custom columns */
std::unordered_map<std::string, int64_t> intColumns;
std::unordered_map<std::string, double> floatColumns;
@@ -168,6 +173,18 @@ public:
const std::string &lastName,
const std::string &templatePath = "",
bool persistent = true);
/**
* Create a runtime-only character with a CALLER-CHOSEN id (the
* deterministic id bands of generated town populations, phase
* 4.7.2). No prefab file, no auto-save; an existing character
* with the same id is left untouched and returned. Returns 0
* when the id collides with the persistent/sequential runtime
* allocators' ranges (< 1<<32, or below the population band
* base).
*/
uint64_t ensureRuntimeCharacter(uint64_t id,
const std::string &firstName,
const std::string &lastName);
void deleteCharacter(uint64_t id);
CharacterRecord *findCharacter(uint64_t id);
const CharacterRecord *findCharacter(uint64_t id) const;
@@ -180,7 +197,10 @@ public:
/* ------------------------------------------------------------------ */
/* Groups / Factions */
/* ------------------------------------------------------------------ */
uint64_t createGroup(const std::string &name);
uint64_t createGroup(const std::string &name, bool persistent = true);
/** Same as ensureRuntimeCharacter but for groups (deterministic
* household group ids of generated populations). */
uint64_t ensureRuntimeGroup(uint64_t id, const std::string &name);
void deleteGroup(uint64_t id);
void addToGroup(uint64_t groupId, uint64_t characterId);
void removeFromGroup(uint64_t groupId, uint64_t characterId);
@@ -378,7 +398,21 @@ public:
/* Auto-save to fixed path (called after mutations) */
void autoSave();
/** Bulk-update guard: while suspended, mutations skip the
* auto-save file write (population generation adds hundreds of
* records at once, phase 4.7.2). Call autoSave() explicitly
* afterwards if persistent data changed. */
void setAutoSaveSuspended(bool suspended)
{
m_autoSaveSuspended = suspended;
}
bool isAutoSaveSuspended() const
{
return m_autoSaveSuspended;
}
private:
bool m_autoSaveSuspended = false;
uint64_t m_nextId = 1;
uint64_t m_nextRuntimeId = 1ull << 32;
@@ -1,4 +1,5 @@
#include "EditorSunSystem.hpp"
#include "GameClockSystem.hpp"
#include "../components/Sun.hpp"
#include "../components/Transform.hpp"
#include <OgreLogManager.h>
@@ -32,12 +33,17 @@ void EditorSunSystem::update(float deltaTime, Ogre::Camera *camera)
sun.dirty = false;
}
// Advance time
sun.timeOfDay += deltaTime * sun.timeSpeed;
if (sun.timeOfDay >= 24.0f)
sun.timeOfDay -= 24.0f;
if (sun.timeOfDay < 0.0f)
sun.timeOfDay += 24.0f;
// Advance time (skipped while the GameClockSystem drives
// the sun — demo-taxi phase 4.7.4: one authoritative
// clock for the schedule layer and the visuals)
GameClockSystem *clock = GameClockSystem::getInstance();
if (!clock || !clock->drivesSun()) {
sun.timeOfDay += deltaTime * sun.timeSpeed;
if (sun.timeOfDay >= 24.0f)
sun.timeOfDay -= 24.0f;
if (sun.timeOfDay < 0.0f)
sun.timeOfDay += 24.0f;
}
// Compute sun angle:
// 6:00 = 0 rad (horizon, east)
@@ -0,0 +1,71 @@
#include "GameClockSystem.hpp"
#include <cmath>
GameClockSystem *GameClockSystem::ms_instance = nullptr;
GameClockSystem::GameClockSystem(flecs::world &world)
: m_world(world)
, m_sunQuery(world.query<SunComponent>())
{
ms_instance = this;
}
GameClockSystem::~GameClockSystem()
{
ms_instance = nullptr;
}
void GameClockSystem::setTime(uint32_t day, float timeOfDayHours)
{
m_day = day;
m_timeOfDay = timeOfDayHours;
while (m_timeOfDay >= 24.0f) {
m_timeOfDay -= 24.0f;
++m_day;
}
while (m_timeOfDay < 0.0f) {
m_timeOfDay += 24.0f;
--m_day;
}
}
void GameClockSystem::update(float deltaTime)
{
if (!m_paused) {
m_timeOfDay += deltaTime * m_speed;
while (m_timeOfDay >= 24.0f) {
m_timeOfDay -= 24.0f;
++m_day;
}
while (m_timeOfDay < 0.0f) {
m_timeOfDay += 24.0f;
--m_day;
}
}
if (!m_driveSun)
return;
/* Adopt the first sun's configured time/speed on sight; later,
* adopt again whenever a sun's time/speed diverges from what the
* clock last wrote (an external edit, e.g. the sun panel
* sliders). Then drive every sun from the clock. */
m_sunQuery.each([this](SunComponent &sun) {
if (!m_adoptedSun) {
m_timeOfDay = sun.timeOfDay;
m_day = 0;
m_speed = sun.timeSpeed;
m_adoptedSun = true;
m_lastWritten = sun.timeOfDay;
return;
}
if (std::fabs(sun.timeOfDay - m_lastWritten) > 1.0e-3f ||
std::fabs(sun.timeSpeed - m_speed) > 1.0e-6f) {
m_timeOfDay = sun.timeOfDay;
m_speed = sun.timeSpeed;
}
sun.timeOfDay = m_timeOfDay;
});
m_lastWritten = m_timeOfDay;
}
@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <flecs.h>
#include "../components/Sun.hpp"
/**
* Game clock (demo-taxi phase 4.7.4).
*
* The one queryable time source for the schedule/goal layer: a day
* counter plus time-of-day hours, pauseable, advanced by frame delta
* (fixed-step friendly — the caller passes the same fixed dt it uses
* for physics). Singleton owned by EditorApp, same pattern as
* NpcPopulationSystem.
*
* Sun integration: with drivesSun (the default) the clock adopts the
* first SunComponent's timeOfDay/timeSpeed on sight, then writes the
* clock time back to every sun each frame — the visuals and the
* schedule clock can never diverge. An external edit (the sun panel
* sliders) is detected by divergence from the last written value and
* re-adopted. EditorSunSystem skips its own time advance while the
* clock drives. Scenes without a GameClockSystem (other demos)
* behave exactly as before.
*/
class GameClockSystem
{
public:
GameClockSystem(flecs::world &world);
~GameClockSystem();
static GameClockSystem *getInstance()
{
return ms_instance;
}
/** Advance the clock (unless paused) and drive the suns.
* Call before EditorSunSystem::update. */
void update(float deltaTime);
uint32_t getDay() const
{
return m_day;
}
/** Hours, [0, 24). */
float getTimeOfDayHours() const
{
return m_timeOfDay;
}
/** day * 24 + timeOfDay. */
float getAbsoluteHours() const
{
return m_day * 24.0f + m_timeOfDay;
}
/** Game-hours per real second. */
float getSpeed() const
{
return m_speed;
}
void setSpeed(float speed)
{
m_speed = speed;
}
bool isPaused() const
{
return m_paused;
}
void setPaused(bool paused)
{
m_paused = paused;
}
void setTime(uint32_t day, float timeOfDayHours);
bool drivesSun() const
{
return m_driveSun;
}
void setDrivesSun(bool drive)
{
m_driveSun = drive;
}
private:
flecs::world &m_world;
flecs::query<SunComponent> m_sunQuery;
uint32_t m_day = 0;
float m_timeOfDay = 12.0f;
/* Matches the SunComponent default. */
float m_speed = 0.05f;
bool m_paused = false;
bool m_driveSun = true;
/* Adopted the initial time/speed from a sun. */
bool m_adoptedSun = false;
/* What the clock last wrote into the suns (external-edit
* divergence detection). */
float m_lastWritten = 12.0f;
static GameClockSystem *ms_instance;
};
@@ -0,0 +1,285 @@
#include "NpcPopulationSystem.hpp"
#include <Ogre.h>
#include "CharacterRegistry.hpp"
#include "RoadSystem.hpp"
#include "TerrainSystem.hpp"
#include "TownGenSystem.hpp"
#include "../components/RoadGraph.hpp"
/* Id layout inside one area's band (AREA_POP_CAP = 1<<20):
* [0, AREA_POP_CAP/2) character ids (building * HOUSEHOLD_ID_CAP +
* member — up to 8192 buildings of up to 63 members), and
* [AREA_POP_CAP/2, AREA_POP_CAP) household group ids. */
static const uint64_t kGroupBandOffset = TownPopulation::AREA_POP_CAP / 2;
/* Population draws are decorrelated from the town layout draws. */
static const uint32_t kPopSeedXor = 0x9e3779b9u;
/* Location rolls are decorrelated from both (phase 4.7.3). */
static const uint32_t kLocSeedXor = 0x85ebca6bu;
/* Schedule draws get their own decorrelation (phase 4.7.4). */
static const uint32_t kScheduleSeedXor = 0xc2b2ae35u;
NpcPopulationSystem *NpcPopulationSystem::ms_instance = nullptr;
NpcPopulationSystem::NpcPopulationSystem(flecs::world &world)
: m_world(world)
{
ms_instance = this;
}
NpcPopulationSystem::~NpcPopulationSystem()
{
ms_instance = nullptr;
}
int NpcPopulationSystem::generateAreaPopulation(int areaIndex,
std::string *error)
{
TerrainSystem *ts = TerrainSystem::getInstance();
TownGenSystem *townGen = ts ? ts->getTownGenSystem() : nullptr;
if (!townGen) {
if (error)
*error = "no terrain system";
return 0;
}
TownGen::TownResult town;
if (!townGen->runAreaGeneration(areaIndex, town, error))
return 0;
if (town.buildings.empty()) {
if (error)
*error = "no buildings in the town area";
return 0;
}
const RoadGraph *graph = nullptr;
flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity();
if (terrain.is_alive() && terrain.has<TerrainComponent>())
graph = &terrain.get<TerrainComponent>().roadGraph;
if (!graph || areaIndex < 0 ||
areaIndex >= (int)graph->townAreas.size()) {
if (error)
*error = "town area index out of range";
return 0;
}
const RoadTownArea &area = graph->townAreas[areaIndex];
TownPopulation::PopulationRules rules;
uint32_t popSeed = area.seed ^ kPopSeedXor;
uint64_t idBase = TownPopulation::NPC_ID_BASE +
(uint64_t)areaIndex * TownPopulation::AREA_POP_CAP;
if (town.buildings.size() * TownPopulation::HOUSEHOLD_ID_CAP >=
kGroupBandOffset) {
if (error)
*error = "too many buildings for the population id "
"band";
return 0;
}
TownPopulation::Population pop =
TownPopulation::generatePopulation(town, popSeed, rules,
idBase);
/* Phase 4.7.3: the area's symbolic locations (home per
* building, curb per pickup POI, parking per parking POI,
* interest subset). */
TownLocations::LocationSet locations =
TownLocations::buildLocations(town, area.seed ^ kLocSeedXor);
CharacterRegistry &reg = CharacterRegistry::getSingleton();
reg.setAutoSaveSuspended(true);
/* Replace exactly what this area generated before. */
clearArea(areaIndex);
AreaPopulation ap;
for (size_t hi = 0; hi < pop.households.size(); ++hi) {
const TownPopulation::Household &hh = pop.households[hi];
uint64_t groupId = reg.ensureRuntimeGroup(
idBase + kGroupBandOffset + (uint64_t)hi,
hh.address + " household");
if (!groupId)
continue;
ap.groupIds.push_back(groupId);
for (size_t mi = 0; mi < hh.members.size(); ++mi) {
const TownPopulation::PopMember &pm =
pop.members[hh.members[mi]];
uint64_t id = reg.ensureRuntimeCharacter(
pm.registryId, pm.firstName, pm.lastName);
if (!id)
continue;
ap.characterIds.push_back(id);
CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(id);
rec->inlineSex = pm.sex;
rec->age = pm.age;
rec->ageYears = pm.ageYears;
rec->inlineOutfitLevel = pm.outfitLevel;
rec->stringColumns["npcKey"] = pm.stableKey;
rec->stringColumns["npcHome"] = hh.address;
rec->stringColumns["npcArea"] = area.name;
/* Phase 4.7.3 location bindings (shared with
* the 4.7.4 schedule generator): work picks
* from the area's Interest locations (a
* placeholder until zoning exists), interest
* from Interest+Curb — both fall back to the
* home location when the town has none. */
TownLocations::NpcBindings bind =
TownLocations::computeBindings(
locations, pm.stableKey, hh.address);
rec->stringColumns["npcWork"] = bind.work;
rec->stringColumns["npcInterest"] = bind.interest;
rec->tags.push_back("npc");
rec->tags.push_back("townie");
/* Symbolic home position: the building centre
* (the motion core moves it from here). */
rec->position = town.buildings[hh.building].center;
reg.addToGroup(groupId, id);
}
/* Household-local relationship indices -> registry ids. */
for (const TownPopulation::PopRelationship &r :
hh.relationships) {
uint64_t fromId =
pop.members[hh.members[r.from]].registryId;
uint64_t toId =
pop.members[hh.members[r.to]].registryId;
reg.addRelationshipTag(fromId, false, toId, false,
r.tag);
}
}
ap.locations = std::move(locations);
/* Phase 4.7.4: per-NPC time-of-day schedules over the area's
* locations (same town state -> same schedules; a script
* forcing a regeneration stays deterministic). */
ap.schedules = TownSchedule::generateSchedules(
pop, ap.locations, area.seed ^ kScheduleSeedXor,
TownSchedule::ScheduleRules());
m_areas[areaIndex] = ap;
reg.setAutoSaveSuspended(false);
Ogre::LogManager::getSingleton().logMessage(
"NpcPopulationSystem[" + area.name + "]: " +
std::to_string(ap.characterIds.size()) + " NPCs in " +
std::to_string(ap.groupIds.size()) + " households");
return (int)ap.characterIds.size();
}
int NpcPopulationSystem::generateAllAreas(std::string *error)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadSystem()) {
if (error)
*error = "no terrain system";
return 0;
}
flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
if (error)
*error = "no active terrain";
return 0;
}
const RoadGraph &graph =
terrain.get<TerrainComponent>().roadGraph;
if (graph.townAreas.empty()) {
if (error)
*error = "no town areas defined";
return 0;
}
int total = 0;
for (size_t ai = 0; ai < graph.townAreas.size(); ++ai) {
std::string areaError;
int n = generateAreaPopulation((int)ai, &areaError);
if (n == 0 && !areaError.empty())
Ogre::LogManager::getSingleton().logMessage(
"NpcPopulationSystem: area " +
graph.townAreas[ai].name + ": " +
areaError);
total += n;
}
return total;
}
int NpcPopulationSystem::clearArea(int areaIndex)
{
auto it = m_areas.find(areaIndex);
if (it == m_areas.end())
return 0;
CharacterRegistry &reg = CharacterRegistry::getSingleton();
int removed = 0;
for (uint64_t id : it->second.characterIds) {
if (reg.findCharacter(id)) {
reg.deleteCharacter(id);
++removed;
}
}
for (uint64_t id : it->second.groupIds) {
if (reg.findGroup(id))
reg.deleteGroup(id);
}
m_areas.erase(it);
return removed;
}
int NpcPopulationSystem::clearAreaPopulation(int areaIndex)
{
CharacterRegistry &reg = CharacterRegistry::getSingleton();
reg.setAutoSaveSuspended(true);
int removed = clearArea(areaIndex);
reg.setAutoSaveSuspended(false);
return removed;
}
int NpcPopulationSystem::clearAllPopulations()
{
CharacterRegistry &reg = CharacterRegistry::getSingleton();
reg.setAutoSaveSuspended(true);
int removed = 0;
while (!m_areas.empty())
removed += clearArea(m_areas.begin()->first);
reg.setAutoSaveSuspended(false);
return removed;
}
int NpcPopulationSystem::countPopulationNpcs() const
{
int n = 0;
for (const auto &kv : m_areas)
n += (int)kv.second.characterIds.size();
return n;
}
std::vector<uint64_t> NpcPopulationSystem::populationCharacterIds() const
{
std::vector<uint64_t> out;
for (const auto &kv : m_areas)
out.insert(out.end(), kv.second.characterIds.begin(),
kv.second.characterIds.end());
return out;
}
const TownLocations::LocationSet *
NpcPopulationSystem::areaLocations(int areaIndex) const
{
auto it = m_areas.find(areaIndex);
return it == m_areas.end() ? nullptr : &it->second.locations;
}
const TownSchedule::NpcSchedule *
NpcPopulationSystem::scheduleOf(uint64_t registryId) const
{
for (const auto &kv : m_areas)
for (const TownSchedule::NpcSchedule &s :
kv.second.schedules)
if (s.registryId == registryId)
return &s;
return nullptr;
}
@@ -0,0 +1,106 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
#include <flecs.h>
#include "../roadlib/TownPopulation.hpp"
#include "../roadlib/TownLocations.hpp"
#include "../roadlib/TownSchedule.hpp"
/**
* NPC population system (demo-taxi phase 4.7.2).
*
* Generates the deterministic population of a town area
* (roadlib/TownPopulation over the area's pure TownGen result,
* re-run through TownGenSystem::runAreaGeneration so it always
* matches the generated/streamed buildings) and applies it to the
* CharacterRegistry as runtime-only entries:
*
* - one runtime GroupRecord per household (deterministic id),
* - one runtime CharacterRecord per member (deterministic id from
* the area's band) with the attribute-driven look fields
* (inlineSex/age/ageYears/inlineOutfitLevel), the home binding and
* the stable key in stringColumns, and the symbolic position at
* the home building,
* - the seeded household relationships (spouse/parent/child/
* sibling, directed).
*
* Re-generation of the same area replaces exactly the entries
* recorded for that area, so a town rebuild never leaks or
* duplicates population entries. Everything is runtime-only:
* nothing reaches character_registry.json.
*
* Phase 4.7.3 (location model): each area also owns its generated
* TownLocations::LocationSet (areaLocations()), and every NPC gets
* deterministic location bindings in stringColumns — npcHome stays
* the household address (the home location is "home:" + address),
* npcWork / npcInterest hold location names picked deterministically
* from the area's Interest (work placeholder until zoning exists)
* and Interest+Curb locations.
*/
class NpcPopulationSystem
{
public:
NpcPopulationSystem(flecs::world &world);
~NpcPopulationSystem();
/** Singleton access (same pattern as TerrainSystem). */
static NpcPopulationSystem *getInstance()
{
return ms_instance;
}
/** Generate (or replace) the population of town area
* @p areaIndex. Returns the number of NPCs (0 on failure,
* @p error set). */
int generateAreaPopulation(int areaIndex, std::string *error);
/** Generate the populations of every town area of the active
* terrain's road graph. Returns the total number of NPCs. */
int generateAllAreas(std::string *error);
/** Remove the generated population of one area. Returns the
* number of character records removed. */
int clearAreaPopulation(int areaIndex);
/** Remove every generated population. */
int clearAllPopulations();
/** Generated population NPCs currently in the registry. */
int countPopulationNpcs() const;
/** All generated population character ids (area order, then
* id band order) — for inspection/debug UIs. */
std::vector<uint64_t> populationCharacterIds() const;
/** The generated location set of an area, nullptr when the
* area has no generated population. */
const TownLocations::LocationSet *areaLocations(int areaIndex) const;
/** The generated schedule of a population NPC (phase 4.7.4),
* nullptr when unknown. Pair with TownSchedule::entryAt() and
* GameClockSystem::getTimeOfDayHours() for the current goal. */
const TownSchedule::NpcSchedule *scheduleOf(uint64_t registryId) const;
private:
/** Ids currently owned by an area (characters + household
* groups) for exact replacement on regeneration, plus the
* area's generated locations and schedules. */
struct AreaPopulation {
std::vector<uint64_t> characterIds;
std::vector<uint64_t> groupIds;
TownLocations::LocationSet locations;
std::vector<TownSchedule::NpcSchedule> schedules;
};
int clearArea(int areaIndex);
flecs::world &m_world;
std::map<int, AreaPopulation> m_areas;
static NpcPopulationSystem *ms_instance;
};
@@ -0,0 +1,200 @@
#include "NpcScheduleDebugOverlay.hpp"
#include <imgui.h>
#include <map>
#include <vector>
#include "../EditorApp.hpp"
#include "CharacterRegistry.hpp"
#include "GameClockSystem.hpp"
#include "NpcPopulationSystem.hpp"
NpcScheduleDebugOverlay::NpcScheduleDebugOverlay(EditorApp *editorApp)
: m_editorApp(editorApp)
{
}
void NpcScheduleDebugOverlay::render()
{
if (!m_visible || !m_editorApp)
return;
if (m_editorApp->getGameMode() != EditorApp::GameMode::Game)
return;
/* Headless runs have no ImGui context. */
if (!ImGui::GetCurrentContext())
return;
GameClockSystem *clock = GameClockSystem::getInstance();
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
ImGui::SetNextWindowSize(ImVec2(620.0f, 520.0f),
ImGuiCond_FirstUseEver);
bool open = m_visible;
if (!ImGui::Begin("NPC Schedules (F7)", &open)) {
ImGui::End();
m_visible = open;
return;
}
const float tod = clock ? clock->getTimeOfDayHours() : 0.0f;
if (clock) {
const int hh = (int)tod;
const int mm = (int)((tod - hh) * 60.0f);
ImGui::Text("Day %u, %02d:%02d", clock->getDay(), hh, mm);
bool paused = clock->isPaused();
if (ImGui::Checkbox("Paused", &paused))
clock->setPaused(paused);
float speed = clock->getSpeed();
if (ImGui::SliderFloat("Speed (game h/s)", &speed, 0.0f,
2.0f))
clock->setSpeed(speed);
float t = tod;
if (ImGui::SliderFloat("Time of Day", &t, 0.0f, 24.0f,
"%.2f h"))
clock->setTime(clock->getDay(), t);
} else {
ImGui::TextUnformatted("No game clock system");
}
ImGui::Separator();
if (!pop) {
ImGui::TextUnformatted("No population system");
ImGui::End();
m_visible = open;
return;
}
CharacterRegistry &reg = CharacterRegistry::getSingleton();
std::vector<uint64_t> ids = pop->populationCharacterIds();
ImGui::Text("%zu population NPCs", ids.size());
if (ids.empty()) {
ImGui::TextUnformatted(
"(generate the town population first)");
ImGui::End();
m_visible = open;
return;
}
if (m_selected >= (int)ids.size())
m_selected = -1;
/* Group by town area (the npcArea string column). */
std::map<std::string, std::vector<uint64_t> > byArea;
for (uint64_t id : ids) {
const CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(id);
std::string area;
if (rec) {
auto it = rec->stringColumns.find("npcArea");
if (it != rec->stringColumns.end())
area = it->second;
}
byArea[area].push_back(id);
}
ImGui::BeginChild("NpcList", ImVec2(300.0f, 0.0f), true);
int flatIndex = 0;
for (const auto &kv : byArea) {
if (!ImGui::TreeNode(kv.first.c_str(), "%s (%zu)",
kv.first.empty() ? "?" :
kv.first.c_str(),
kv.second.size()))
continue;
for (uint64_t id : kv.second) {
const CharacterRegistry::CharacterRecord *rec =
reg.findCharacter(id);
if (!rec)
continue;
const TownSchedule::NpcSchedule *sched =
pop->scheduleOf(id);
const TownSchedule::ScheduleEntry *entry =
sched ? TownSchedule::entryAt(*sched, tod) :
nullptr;
std::string label = rec->firstName + " " +
rec->lastName;
if (entry)
label += " | " + entry->activity + " @ " +
entry->location;
if (ImGui::Selectable(label.c_str(),
m_selected == flatIndex))
m_selected = flatIndex;
++flatIndex;
}
ImGui::TreePop();
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("NpcDetails", ImVec2(0.0f, 0.0f), true);
if (m_selected >= 0) {
/* Re-flatten to find the selected id. */
uint64_t selId = 0;
int fi = 0;
for (const auto &kv : byArea)
for (uint64_t id : kv.second) {
if (fi == m_selected)
selId = id;
++fi;
}
const CharacterRegistry::CharacterRecord *rec =
selId ? reg.findCharacter(selId) : nullptr;
const TownSchedule::NpcSchedule *sched =
selId ? pop->scheduleOf(selId) : nullptr;
if (rec && sched) {
ImGui::Text("%s %s (%s, %d y)", rec->firstName.c_str(),
rec->lastName.c_str(), rec->age.c_str(),
rec->ageYears);
auto col = [rec](const char *key) -> std::string {
auto it = rec->stringColumns.find(key);
return it == rec->stringColumns.end() ?
std::string() :
it->second;
};
ImGui::Text("home: %s", col("npcHome").c_str());
ImGui::Text("work: %s", col("npcWork").c_str());
ImGui::Text("interest: %s",
col("npcInterest").c_str());
ImGui::Separator();
if (ImGui::BeginTable("schedule", 4,
ImGuiTableFlags_Borders |
ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("From");
ImGui::TableSetupColumn("To");
ImGui::TableSetupColumn("Activity");
ImGui::TableSetupColumn("Location");
ImGui::TableHeadersRow();
for (const TownSchedule::ScheduleEntry &e :
sched->entries) {
const bool active = tod >= e.startHour &&
tod < e.endHour;
ImGui::TableNextRow();
if (active)
ImGui::TableSetBgColor(
ImGuiTableBgTarget_RowBg0,
IM_COL32(60, 90, 60,
128));
ImGui::TableNextColumn();
ImGui::Text("%05.2f", e.startHour);
ImGui::TableNextColumn();
ImGui::Text("%05.2f", e.endHour);
ImGui::TableNextColumn();
ImGui::TextUnformatted(
e.activity.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(
e.location.c_str());
}
ImGui::EndTable();
}
} else {
ImGui::TextUnformatted("(no schedule)");
}
} else {
ImGui::TextUnformatted("Select an NPC");
}
ImGui::EndChild();
ImGui::End();
m_visible = open;
}
@@ -0,0 +1,37 @@
#pragma once
class EditorApp;
/**
* NPC schedule debug overlay (demo-taxi phase 4.7.4).
*
* Game-mode-only ImGui window toggled with F7: game clock readout
* with pause/speed/time-of-day controls, the per-town-area list of
* population NPCs with the schedule entry active at the current
* time, and the selected NPC's full 24h schedule + location
* bindings. Read-only for everything except the clock.
*/
class NpcScheduleDebugOverlay
{
public:
NpcScheduleDebugOverlay(EditorApp *editorApp);
void toggle()
{
m_visible = !m_visible;
}
bool isVisible() const
{
return m_visible;
}
/** Render the window (no-op unless visible in game mode with a
* live ImGui context). */
void render();
private:
EditorApp *m_editorApp;
bool m_visible = false;
/** Index into the flattened population id list, -1 = none. */
int m_selected = -1;
};
@@ -50,6 +50,7 @@
#include "../components/Actuator.hpp"
#include "../components/Pushable.hpp"
#include "../components/TargetZone.hpp"
#include "../components/NpcLocation.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
@@ -422,6 +423,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
if (entity.has<TargetZoneComponent>()) {
json["targetZone"] = serializeTargetZone(entity);
}
if (entity.has<NpcLocationComponent>()) {
json["npcLocation"] = serializeNpcLocation(entity);
}
if (entity.has<EventHandlerComponent>()) {
json["eventHandler"] = serializeEventHandler(entity);
}
@@ -677,6 +681,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
if (json.contains("targetZone")) {
deserializeTargetZone(entity, json["targetZone"]);
}
if (json.contains("npcLocation")) {
deserializeNpcLocation(entity, json["npcLocation"]);
}
if (json.contains("eventHandler")) {
deserializeEventHandler(entity, json["eventHandler"]);
}
@@ -1008,6 +1015,9 @@ void SceneSerializer::deserializeEntityComponents(
if (json.contains("targetZone")) {
deserializeTargetZone(entity, json["targetZone"]);
}
if (json.contains("npcLocation")) {
deserializeNpcLocation(entity, json["npcLocation"]);
}
if (json.contains("sceneScript")) {
deserializeSceneScript(entity, json["sceneScript"]);
}
@@ -4509,6 +4519,28 @@ void SceneSerializer::deserializeTargetZone(flecs::entity entity,
entity.set<TargetZoneComponent>(zone);
}
nlohmann::json SceneSerializer::serializeNpcLocation(flecs::entity entity)
{
const NpcLocationComponent &loc = entity.get<NpcLocationComponent>();
nlohmann::json json;
json["kind"] = loc.kind;
json["name"] = loc.name;
json["radius"] = loc.radius;
json["enabled"] = loc.enabled;
return json;
}
void SceneSerializer::deserializeNpcLocation(flecs::entity entity,
const nlohmann::json &json)
{
NpcLocationComponent loc;
loc.kind = json.value("kind", 3);
loc.name = json.value("name", "");
loc.radius = json.value("radius", 2.0f);
loc.enabled = json.value("enabled", true);
entity.set<NpcLocationComponent>(loc);
}
nlohmann::json SceneSerializer::serializeEventHandler(flecs::entity entity)
{
const EventHandlerComponent &handler =
@@ -281,6 +281,7 @@ private:
nlohmann::json serializeActuator(flecs::entity entity);
nlohmann::json serializePushable(flecs::entity entity);
nlohmann::json serializeTargetZone(flecs::entity entity);
nlohmann::json serializeNpcLocation(flecs::entity entity);
nlohmann::json serializeEventHandler(flecs::entity entity);
nlohmann::json serializeSceneScript(flecs::entity entity);
nlohmann::json serializeStandaloneDoor(flecs::entity entity);
@@ -300,6 +301,8 @@ private:
const nlohmann::json &json);
void deserializeTargetZone(flecs::entity entity,
const nlohmann::json &json);
void deserializeNpcLocation(flecs::entity entity,
const nlohmann::json &json);
void deserializeEventHandler(flecs::entity entity,
const nlohmann::json &json);
void deserializeSceneScript(flecs::entity entity,
@@ -448,6 +448,47 @@ int TownGenSystem::generateAreas(std::string *error)
return total;
}
bool TownGenSystem::runAreaGeneration(int areaIndex,
TownGen::TownResult &out,
std::string *error)
{
TerrainSystem *ts = m_terrainSystem ?
m_terrainSystem :
TerrainSystem::getInstance();
const RoadGraph *graph = nullptr;
float waterY = -FLT_MAX;
RoadNetworkGen::HeightSampler sampler;
if (!setupTownRun(ts, &graph, &waterY, &sampler, m_world, error))
return false;
if (areaIndex < 0 || areaIndex >= (int)graph->townAreas.size()) {
if (error)
*error = "town area index out of range";
return false;
}
const RoadTownArea &area = graph->townAreas[areaIndex];
TownGen::TownParams p;
p.district = (TownGen::District)(int)area.district;
p.seed = area.seed;
p.densityScale = area.densityScale;
p.waterLevel = waterY;
LayoutLib::LayoutLibrary layouts = loadLayoutLibrary();
/* See generate(): paged generation on streaming terrains. */
TownGen::PageScope scope;
const TownGen::PageScope *scopePtr = nullptr;
if (ts->getStreamingActive() && ts->getTerrainGroup()) {
scope.mapper = makePageMapper(ts);
scopePtr = &scope;
}
out = TownGen::generateTown(*graph, p, sampler, &layouts,
&graph->townAreas, areaIndex, scopePtr);
return true;
}
int TownGenSystem::regenerateBuilding(const std::string &address,
const std::string &genMode,
std::string *error)
@@ -59,6 +59,16 @@ public:
* set — also when the graph defines no town areas). */
int generateAreas(std::string *error);
/** Phase 4.7.2: re-run the PURE generation of one town area
* (no entities created) and return the TownResult — the
* population/location generators consume it. Uses the same
* graph, area params, height sampler, layout library and page
* scope as generateAreas(), so the result matches the generated
* (or streamed) town exactly. Returns false with @p error set
* on failure. */
bool runAreaGeneration(int areaIndex, TownGen::TownResult &out,
std::string *error);
/** Per-frame driver (phase 4.5 item 5, called from
* TerrainSystem::update). Game mode + streaming terrain + town
* areas in the graph: regenerates each loaded page's buildings
@@ -37,6 +37,9 @@
#include "roadlib/RoadGeometryLib.hpp"
#include "roadlib/RoadNetworkGen.hpp"
#include "roadlib/TownGen.hpp"
#include "roadlib/TownPopulation.hpp"
#include "roadlib/TownLocations.hpp"
#include "roadlib/TownSchedule.hpp"
#include <algorithm>
#include <cfloat>
@@ -4837,6 +4840,775 @@ static bool runTownGenProbe()
printf(" %s\n", ok ? "passed" : "FAILURES above");
return ok;
}
/* Phase 4.7.2 probe: population generation over a generated town.
* Determinism (same seed -> identical population), per-building RNG
* isolation (renaming one building shifts only its own household),
* household composition invariants, relationship symmetry and
* registry id uniqueness. */
static bool runTownPopulationProbe()
{
printf("town population probe\n");
bool ok = true;
RoadGraph graph;
{
int ids[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
ids[i][j] = graph.addNode(
Vector3(i * 100.0f, 0, j * 100.0f),
0.0f);
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
if (i < 2)
graph.addEdge(ids[i][j],
ids[i + 1][j]);
if (j < 2)
graph.addEdge(ids[i][j],
ids[i][j + 1]);
}
}
RoadNetworkGen::HeightSampler flat = [](float, float) {
return 0.0f;
};
TownGen::TownParams cc;
cc.district = TownGen::District::CityCenter;
cc.seed = 7;
TownGen::TownResult town = TownGen::generateTown(graph, cc, flat);
if (town.buildings.size() != 12) {
printf(" *** setup: %zu buildings, want 12 ***\n",
town.buildings.size());
return false;
}
TownPopulation::PopulationRules rules;
const uint32_t popSeed = 42;
TownPopulation::Population a =
TownPopulation::generatePopulation(town, popSeed, rules);
TownPopulation::Population b =
TownPopulation::generatePopulation(town, popSeed, rules);
/* Determinism: identical seed -> identical output. */
auto sameMember = [](const TownPopulation::PopMember &x,
const TownPopulation::PopMember &y) {
return x.registryId == y.registryId &&
x.stableKey == y.stableKey &&
x.household == y.household &&
x.firstName == y.firstName &&
x.lastName == y.lastName && x.sex == y.sex &&
x.age == y.age && x.ageYears == y.ageYears &&
x.outfitLevel == y.outfitLevel;
};
if (a.members.size() != b.members.size() ||
a.households.size() != b.households.size()) {
printf(" *** determinism: size mismatch ***\n");
ok = false;
} else {
for (size_t i = 0; i < a.members.size(); ++i) {
if (!sameMember(a.members[i], b.members[i])) {
printf(" *** determinism: member %zu "
"differs ***\n", i);
ok = false;
break;
}
}
for (size_t i = 0; i < a.households.size(); ++i) {
const TownPopulation::Household &ha =
a.households[i];
const TownPopulation::Household &hb =
b.households[i];
if (ha.members != hb.members ||
ha.relationships.size() !=
hb.relationships.size()) {
printf(" *** determinism: household %zu "
"differs ***\n", i);
ok = false;
break;
}
for (size_t r = 0; r < ha.relationships.size(); ++r) {
const TownPopulation::PopRelationship &ra =
ha.relationships[r];
const TownPopulation::PopRelationship &rb =
hb.relationships[r];
if (ra.from != rb.from || ra.to != rb.to ||
ra.tag != rb.tag) {
printf(" *** determinism: "
"relationship %zu of "
"household %zu differs ***\n",
r, i);
ok = false;
break;
}
}
}
}
/* Invariants. */
if (a.households.size() != town.buildings.size()) {
printf(" *** %zu households for %zu buildings ***\n",
a.households.size(), town.buildings.size());
ok = false;
}
std::set<uint64_t> ids;
for (size_t hi = 0; hi < a.households.size(); ++hi) {
const TownPopulation::Household &hh = a.households[hi];
int size = (int)hh.members.size();
if (size < rules.minHouseholdSize ||
size > rules.maxHouseholdSize) {
printf(" *** household %zu size %d out of "
"[%d,%d] ***\n", hi, size,
rules.minHouseholdSize,
rules.maxHouseholdSize);
ok = false;
}
int adults = 0;
for (size_t mi = 0; mi < hh.members.size(); ++mi) {
int gi = hh.members[mi];
if (gi < 0 || gi >= (int)a.members.size()) {
printf(" *** household %zu member index "
"%d out of range ***\n", hi, gi);
ok = false;
continue;
}
const TownPopulation::PopMember &m = a.members[gi];
if (m.household != (int)hi) {
printf(" *** member %d back-pointer %d, "
"want %zu ***\n", gi, m.household, hi);
ok = false;
}
if (m.lastName != hh.lastName) {
printf(" *** member %d lastName '%s', "
"want family '%s' ***\n", gi,
m.lastName.c_str(),
hh.lastName.c_str());
ok = false;
}
bool isChild = m.age == "child";
bool isAdult = m.age == "adult";
bool isElder = m.age == "elder";
if (!isChild && !isAdult && !isElder) {
printf(" *** member %d bad age band "
"'%s' ***\n", gi, m.age.c_str());
ok = false;
}
if (isChild &&
(m.ageYears < rules.childMinYears ||
m.ageYears > rules.childMaxYears)) {
printf(" *** child ageYears %d ***\n",
m.ageYears);
ok = false;
}
if (isAdult &&
(m.ageYears < rules.adultMinYears ||
m.ageYears > rules.adultMaxYears)) {
printf(" *** adult ageYears %d ***\n",
m.ageYears);
ok = false;
}
if (isElder &&
(m.ageYears < rules.elderMinYears ||
m.ageYears > rules.elderMaxYears)) {
printf(" *** elder ageYears %d ***\n",
m.ageYears);
ok = false;
}
/* Adults come first in the member list. */
if (!isChild) {
++adults;
if (mi >= 2) {
printf(" *** adult at member "
"slot %zu ***\n", mi);
ok = false;
}
}
if (m.sex != "male" && m.sex != "female") {
printf(" *** member %d bad sex '%s' ***\n",
gi, m.sex.c_str());
ok = false;
}
if (!ids.insert(m.registryId).second) {
printf(" *** duplicate registryId %llu "
"***\n",
(unsigned long long)m.registryId);
ok = false;
}
if (m.registryId < TownPopulation::NPC_ID_BASE ||
m.registryId >= TownPopulation::NPC_ID_BASE +
town.buildings.size() *
TownPopulation::HOUSEHOLD_ID_CAP) {
printf(" *** registryId %llu outside the "
"per-building bands ***\n",
(unsigned long long)m.registryId);
ok = false;
}
}
if (adults < 1 || adults > 2) {
printf(" *** household %zu has %d adults ***\n",
hi, adults);
ok = false;
}
/* Relationship sanity: indices in range, spouse
* symmetric, child tags go child -> adult. */
for (const TownPopulation::PopRelationship &r :
hh.relationships) {
if (r.from < 0 || r.from >= size || r.to < 0 ||
r.to >= size || r.from == r.to) {
printf(" *** household %zu bad "
"relationship %d->%d '%s' ***\n", hi,
r.from, r.to, r.tag.c_str());
ok = false;
continue;
}
const TownPopulation::PopMember &mf =
a.members[hh.members[r.from]];
const TownPopulation::PopMember &mt =
a.members[hh.members[r.to]];
if (r.tag == "spouse") {
bool back = false;
for (const TownPopulation::PopRelationship &q :
hh.relationships)
if (q.from == r.to &&
q.to == r.from &&
q.tag == "spouse")
back = true;
if (!back) {
printf(" *** asymmetric spouse in "
"household %zu ***\n", hi);
ok = false;
}
if (mf.age == "child" ||
mt.age == "child") {
printf(" *** spouse involving a "
"child ***\n");
ok = false;
}
}
if (r.tag == "child" &&
(mf.age != "child" || mt.age == "child")) {
printf(" *** 'child' tag %s -> %s ***\n",
mf.age.c_str(), mt.age.c_str());
ok = false;
}
}
}
/* Per-building RNG isolation: renaming one building in a copy
* of the town must change only that building's household (its
* size may change too, shifting the global member list — the
* households are compared pairwise, not the flat arrays). */
{
TownGen::TownResult town2 = town;
town2.buildings[5].address += " renamed";
TownPopulation::Population c =
TownPopulation::generatePopulation(town2, popSeed,
rules);
if (c.households.size() != a.households.size()) {
printf(" *** isolation: household count %zu -> "
"%zu ***\n", a.households.size(),
c.households.size());
ok = false;
} else {
for (size_t hi = 0; hi < a.households.size(); ++hi) {
bool expectSame = hi != 5;
bool same = true;
const TownPopulation::Household &ha =
a.households[hi];
const TownPopulation::Household &hc =
c.households[hi];
if (ha.members.size() != hc.members.size()) {
same = false;
} else {
for (size_t mi = 0;
mi < ha.members.size(); ++mi) {
if (!sameMember(
a.members[ha.members
[mi]],
c.members[hc.members
[mi]]))
same = false;
}
}
if (expectSame && !same) {
printf(" *** isolation: household "
"%zu changed ***\n", hi);
ok = false;
}
if (!expectSame && same) {
printf(" *** isolation: renamed "
"household unchanged ***\n");
ok = false;
}
}
}
}
/* Different seed -> different population. */
{
TownPopulation::Population d =
TownPopulation::generatePopulation(town, popSeed + 1,
rules);
bool anyDiff = d.members.size() != a.members.size();
if (!anyDiff)
for (size_t i = 0; i < a.members.size(); ++i)
if (!sameMember(a.members[i], d.members[i])) {
anyDiff = true;
break;
}
if (!anyDiff) {
printf(" *** seed change produced identical "
"population ***\n");
ok = false;
}
}
printf(" %s (%zu buildings, %zu households, %zu members)\n",
ok ? "passed" : "FAILURES above", town.buildings.size(),
a.households.size(), a.members.size());
return ok;
}
static bool runTownLocationsProbe()
{
printf("town locations probe\n");
bool ok = true;
RoadGraph graph;
{
int ids[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
ids[i][j] = graph.addNode(
Vector3(i * 100.0f, 0, j * 100.0f),
0.0f);
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
if (i < 2)
graph.addEdge(ids[i][j],
ids[i + 1][j]);
if (j < 2)
graph.addEdge(ids[i][j],
ids[i][j + 1]);
}
}
RoadNetworkGen::HeightSampler flat = [](float, float) {
return 0.0f;
};
TownGen::TownParams cc;
cc.district = TownGen::District::CityCenter;
cc.seed = 7;
TownGen::TownResult town = TownGen::generateTown(graph, cc, flat);
if (town.buildings.size() != 12) {
printf(" *** setup: %zu buildings, want 12 ***\n",
town.buildings.size());
return false;
}
const uint32_t seed = 42;
TownLocations::LocationSet a =
TownLocations::buildLocations(town, seed);
TownLocations::LocationSet b =
TownLocations::buildLocations(town, seed);
auto sameLocation = [](const TownLocations::NpcLocation &x,
const TownLocations::NpcLocation &y) {
return x.id == y.id && x.kind == y.kind &&
x.name == y.name && x.address == y.address &&
x.position == y.position && x.building == y.building;
};
/* Determinism: identical seed -> identical set. */
if (a.locations.size() != b.locations.size()) {
printf(" *** determinism: size mismatch %zu vs %zu ***\n",
a.locations.size(), b.locations.size());
ok = false;
} else {
for (size_t i = 0; i < a.locations.size(); ++i) {
if (!sameLocation(a.locations[i], b.locations[i])) {
printf(" *** determinism: location %zu "
"differs ***\n", i);
ok = false;
break;
}
}
}
/* Counts: one Home per building, one Curb per pickup POI, one
* Parking per parking POI. */
size_t pickups = 0, parkings = 0;
for (const TownGen::TownPoi &poi : town.pois)
if (poi.kind == TownGen::TownPoi::Parking)
++parkings;
else
++pickups;
std::vector<int> homes =
a.indicesOfKind(TownLocations::LocationKind::Home);
std::vector<int> curbs =
a.indicesOfKind(TownLocations::LocationKind::Curb);
std::vector<int> parks =
a.indicesOfKind(TownLocations::LocationKind::Parking);
if (homes.size() != town.buildings.size()) {
printf(" *** %zu homes for %zu buildings ***\n",
homes.size(), town.buildings.size());
ok = false;
}
if (curbs.size() != pickups || parks.size() != parkings) {
printf(" *** %zu curbs (%zu pickups), %zu parkings "
"(%zu POIs) ***\n", curbs.size(), pickups,
parks.size(), parkings);
ok = false;
}
/* Interest ratio: 0 -> none, 1 -> one per building. */
if (!TownLocations::buildLocations(town, seed, 0.0f)
.indicesOfKind(TownLocations::LocationKind::Interest)
.empty()) {
printf(" *** interest ratio 0 still yields interest "
"locations ***\n");
ok = false;
}
if (TownLocations::buildLocations(town, seed, 1.0f)
.indicesOfKind(TownLocations::LocationKind::Interest)
.size() != town.buildings.size()) {
printf(" *** interest ratio 1 does not cover every "
"building ***\n");
ok = false;
}
/* Lookup helpers: ids are the vector index, homeOfBuilding and
* findByName resolve, names are unique. */
std::set<std::string> names;
for (size_t i = 0; i < a.locations.size(); ++i) {
const TownLocations::NpcLocation &loc = a.locations[i];
if (loc.id != (int)i) {
printf(" *** location %zu has id %d ***\n", i,
loc.id);
ok = false;
}
if (!names.insert(loc.name).second) {
printf(" *** duplicate location name '%s' ***\n",
loc.name.c_str());
ok = false;
}
if (a.findByName(loc.name) != &loc) {
printf(" *** findByName('%s') mismatch ***\n",
loc.name.c_str());
ok = false;
}
}
for (size_t bi = 0; bi < town.buildings.size(); ++bi) {
int hi = a.homeOfBuilding((int)bi);
if (hi < 0 || a.locations[hi].building != (int)bi ||
a.locations[hi].kind != TownLocations::LocationKind::Home) {
printf(" *** building %zu home lookup failed ***\n",
bi);
ok = false;
}
}
/* Per-building isolation: renaming one building in a copy may
* only change that building's locations. */
{
TownGen::TownResult town2 = town;
town2.buildings[5].address += " renamed";
TownLocations::LocationSet c =
TownLocations::buildLocations(town2, seed);
if (c.locations.size() != a.locations.size()) {
printf(" *** isolation: location count %zu -> "
"%zu ***\n", a.locations.size(),
c.locations.size());
ok = false;
} else {
for (size_t i = 0; i < a.locations.size(); ++i) {
bool expectSame =
a.locations[i].building != 5;
bool same = sameLocation(a.locations[i],
c.locations[i]);
if (expectSame && !same) {
printf(" *** isolation: location "
"%zu ('%s') changed ***\n", i,
a.locations[i].name.c_str());
ok = false;
}
}
}
}
/* A different seed may only move the interest rolls: every
* non-interest location must exist unchanged (compared by name,
* as interest insertions shift the vector). */
{
TownLocations::LocationSet d =
TownLocations::buildLocations(town, seed + 1);
for (const TownLocations::NpcLocation &x : a.locations) {
if (x.kind == TownLocations::LocationKind::Interest)
continue;
const TownLocations::NpcLocation *y =
d.findByName(x.name);
if (!y || y->kind != x.kind ||
y->address != x.address ||
y->position != x.position ||
y->building != x.building) {
printf(" *** seed change moved "
"non-interest location '%s' ***\n",
x.name.c_str());
ok = false;
break;
}
}
}
printf(" %s (%zu locations: %zu homes, %zu curbs, %zu parkings, "
"%zu interest)\n", ok ? "passed" : "FAILURES above",
a.locations.size(), homes.size(), curbs.size(), parks.size(),
a.indicesOfKind(TownLocations::LocationKind::Interest).size());
return ok;
}
static bool runTownScheduleProbe()
{
printf("town schedule probe\n");
bool ok = true;
RoadGraph graph;
{
int ids[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
ids[i][j] = graph.addNode(
Vector3(i * 100.0f, 0, j * 100.0f),
0.0f);
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
if (i < 2)
graph.addEdge(ids[i][j],
ids[i + 1][j]);
if (j < 2)
graph.addEdge(ids[i][j],
ids[i][j + 1]);
}
}
RoadNetworkGen::HeightSampler flat = [](float, float) {
return 0.0f;
};
TownGen::TownParams cc;
cc.district = TownGen::District::CityCenter;
cc.seed = 7;
TownGen::TownResult town = TownGen::generateTown(graph, cc, flat);
if (town.buildings.size() != 12) {
printf(" *** setup: %zu buildings, want 12 ***\n",
town.buildings.size());
return false;
}
TownPopulation::PopulationRules popRules;
TownPopulation::Population pop =
TownPopulation::generatePopulation(town, 42, popRules);
TownLocations::LocationSet locs =
TownLocations::buildLocations(town, 42);
TownSchedule::ScheduleRules rules;
const uint32_t seed = 99;
std::vector<TownSchedule::NpcSchedule> a =
TownSchedule::generateSchedules(pop, locs, seed, rules);
std::vector<TownSchedule::NpcSchedule> b =
TownSchedule::generateSchedules(pop, locs, seed, rules);
auto sameEntry = [](const TownSchedule::ScheduleEntry &x,
const TownSchedule::ScheduleEntry &y) {
return x.startHour == y.startHour && x.endHour == y.endHour &&
x.location == y.location && x.activity == y.activity;
};
auto sameSchedule = [&sameEntry](const TownSchedule::NpcSchedule &x,
const TownSchedule::NpcSchedule &y) {
if (x.registryId != y.registryId || x.stableKey != y.stableKey ||
x.entries.size() != y.entries.size())
return false;
for (size_t i = 0; i < x.entries.size(); ++i)
if (!sameEntry(x.entries[i], y.entries[i]))
return false;
return true;
};
/* Determinism: identical seed -> identical schedules. */
if (a.size() != pop.members.size() || b.size() != a.size()) {
printf(" *** size: %zu schedules for %zu members ***\n",
a.size(), pop.members.size());
ok = false;
} else {
for (size_t i = 0; i < a.size(); ++i) {
if (!sameSchedule(a[i], b[i])) {
printf(" *** determinism: schedule %zu "
"differs ***\n", i);
ok = false;
break;
}
}
}
/* Per-schedule invariants: tiling of [0, 24), sorted entries,
* existing locations, adults work (never children). */
for (size_t i = 0; i < a.size(); ++i) {
const TownSchedule::NpcSchedule &s = a[i];
if (s.entries.empty()) {
printf(" *** schedule %zu is empty ***\n", i);
ok = false;
continue;
}
float t = 0.0f;
for (size_t e = 0; e < s.entries.size(); ++e) {
const TownSchedule::ScheduleEntry &en = s.entries[e];
if (std::fabs(en.startHour - t) > 1.0e-3f ||
en.endHour <= en.startHour) {
printf(" *** schedule %zu entry %zu breaks "
"tiling at %.3f ***\n", i, e, t);
ok = false;
break;
}
t = en.endHour;
if (!locs.findByName(en.location)) {
printf(" *** schedule %zu entry %zu at "
"unknown location '%s' ***\n", i, e,
en.location.c_str());
ok = false;
}
if (en.activity != "sleep" && en.activity != "home" &&
en.activity != "work" && en.activity != "errand") {
printf(" *** schedule %zu entry %zu bad "
"activity '%s' ***\n", i, e,
en.activity.c_str());
ok = false;
}
}
if (std::fabs(t - 24.0f) > 1.0e-3f) {
printf(" *** schedule %zu ends at %.3f, not 24 "
"***\n", i, t);
ok = false;
}
/* entryAt must agree with a manual scan at sample
* times. */
for (float sample = 0.25f; sample < 24.0f;
sample += 1.37f) {
const TownSchedule::ScheduleEntry *e =
TownSchedule::entryAt(s, sample);
if (!e || sample < e->startHour - 1.0e-3f ||
sample >= e->endHour + 1.0e-3f) {
printf(" *** entryAt(%.2f) wrong on "
"schedule %zu ***\n", sample, i);
ok = false;
break;
}
}
/* Band rules vs the bindings. */
const TownPopulation::PopMember *pm = nullptr;
for (const TownPopulation::PopMember &m : pop.members)
if (m.registryId == s.registryId) {
pm = &m;
break;
}
if (!pm) {
printf(" *** schedule %zu registryId not in the "
"population ***\n", i);
ok = false;
continue;
}
TownLocations::NpcBindings bind =
TownLocations::computeBindings(
locs, pm->stableKey,
pop.households[pm->household].address);
bool hasWork = false;
bool locationsMatch = true;
for (const TownSchedule::ScheduleEntry &en : s.entries) {
if (en.activity == "work") {
hasWork = true;
if (en.location != bind.work)
locationsMatch = false;
}
if (en.activity == "errand" &&
en.location != bind.interest)
locationsMatch = false;
if ((en.activity == "sleep" ||
en.activity == "home") &&
en.location != bind.home)
locationsMatch = false;
}
if (!locationsMatch) {
printf(" *** schedule %zu location/binding "
"mismatch ***\n", i);
ok = false;
}
if (pm->age == "adult" && !hasWork) {
printf(" *** adult schedule %zu has no work "
"***\n", i);
ok = false;
}
if (pm->age != "adult" && hasWork) {
printf(" *** %s schedule %zu works ***\n",
pm->age.c_str(), i);
ok = false;
}
}
/* Per-NPC isolation: renaming one building changes its own
* members' schedules; other members change only when their
* bindings referenced the renamed building's locations (the
* rendezvous pick rebinds exactly those). */
{
TownGen::TownResult town2 = town;
town2.buildings[5].address += " renamed";
TownPopulation::Population pop2 =
TownPopulation::generatePopulation(town2, 42,
popRules);
TownLocations::LocationSet locs2 =
TownLocations::buildLocations(town2, 42);
std::vector<TownSchedule::NpcSchedule> c =
TownSchedule::generateSchedules(pop2, locs2, seed,
rules);
const std::string &addr5 = town.buildings[5].address;
std::set<uint64_t> renamed;
for (const TownPopulation::PopMember &m : pop2.members)
if (pop2.households[m.household].building == 5)
renamed.insert(m.registryId);
/* Match by registryId — a household size change shifts
* the flat member array. */
std::map<uint64_t, const TownSchedule::NpcSchedule *> byId;
for (const TownSchedule::NpcSchedule &s : c)
byId[s.registryId] = &s;
for (const TownSchedule::NpcSchedule &sa : a) {
auto it = byId.find(sa.registryId);
if (it == byId.end())
continue; /* member vanished with the rename */
bool ownMember = renamed.count(sa.registryId) != 0;
bool references5 = false;
for (const TownSchedule::ScheduleEntry &en :
sa.entries)
if (en.location.find(addr5) !=
std::string::npos)
references5 = true;
bool expectSame = !ownMember && !references5;
bool same = sameSchedule(sa, *it->second);
if (expectSame && !same) {
printf(" *** isolation: schedule of %llu "
"changed ***\n",
(unsigned long long)sa.registryId);
ok = false;
}
if (ownMember && same) {
printf(" *** isolation: renamed member "
"schedule of %llu unchanged ***\n",
(unsigned long long)sa.registryId);
ok = false;
}
}
}
printf(" %s (%zu schedules)\n", ok ? "passed" : "FAILURES above",
a.size());
return ok;
}
static bool runGeneratorProbe()
{
printf("grid generator probe\n");
@@ -6443,6 +7215,9 @@ int main(int argc, char **argv)
ok &= runStreetProbe();
ok &= runGeneratorProbe();
ok &= runTownGenProbe();
ok &= runTownPopulationProbe();
ok &= runTownLocationsProbe();
ok &= runTownScheduleProbe();
ok &= runLaneTransitionTaper();
ok &= runApronSkirt();
@@ -0,0 +1,51 @@
#include "NpcLocationEditor.hpp"
#include <imgui.h>
#include <cstring>
#include "../roadlib/TownLocations.hpp"
bool NpcLocationEditor::renderComponent(flecs::entity entity,
NpcLocationComponent &loc)
{
(void)entity;
bool modified = false;
const int kindCount = (int)TownLocations::LocationKind::COUNT;
if (ImGui::BeginCombo("Kind", TownLocations::locationKindName(
(TownLocations::LocationKind)
(loc.kind >= 0 && loc.kind < kindCount ?
loc.kind : 3)))) {
for (int k = 0; k < kindCount; ++k) {
const bool sel = loc.kind == k;
if (ImGui::Selectable(TownLocations::locationKindName(
(TownLocations::LocationKind)k),
sel)) {
loc.kind = k;
modified = true;
}
if (sel)
ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
char nameBuf[256] = { 0 };
strncpy(nameBuf, loc.name.c_str(), sizeof(nameBuf) - 1);
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf))) {
loc.name = nameBuf;
modified = true;
}
if (ImGui::InputFloat("Radius", &loc.radius, 0.1f, 1.0f))
modified = true;
if (ImGui::Checkbox("Enabled", &loc.enabled))
modified = true;
ImGui::TextWrapped(
"Authored NPC location (phase 4.7.3): a named symbolic "
"place NPCs can bind to and travel between. The entity "
"transform is the location position; the name is the "
"binding key and must be unique within the town area.");
return modified;
}
@@ -0,0 +1,20 @@
#ifndef EDITSCENE_NPC_LOCATION_EDITOR_HPP
#define EDITSCENE_NPC_LOCATION_EDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/NpcLocation.hpp"
class NpcLocationEditor : public ComponentEditor<NpcLocationComponent> {
public:
const char *getName() const override
{
return "NpcLocation";
}
protected:
bool renderComponent(flecs::entity entity,
NpcLocationComponent &loc) override;
};
#endif // EDITSCENE_NPC_LOCATION_EDITOR_HPP
+4
View File
@@ -26,6 +26,10 @@ public:
if (ImGui::SliderFloat("Time of Day", &component.timeOfDay, 0.0f,
24.0f, "%.2f h")) {
changed = true;
/* No markDirty: the GameClockSystem adopts
* externally-edited time/speed by divergence
* (phase 4.7.4) and dirty triggers a full
* light/sphere rebuild. */
}
if (ImGui::SliderFloat("Time Speed",
@@ -9,6 +9,7 @@
#include "../systems/TerrainSystem.hpp"
#include "../systems/RoadSystem.hpp"
#include "../systems/TownGenSystem.hpp"
#include "../systems/NpcPopulationSystem.hpp"
#include "../layoutlib/LayoutEval.hpp"
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
#include "../systems/PrefabSystem.hpp"
@@ -1563,6 +1564,37 @@ private:
"district/seed/density; only lots whose "
"centroid it owns). Replaces all "
"generated towns.");
ImGui::SameLine();
if (ImGui::Button("Generate Population")) {
NpcPopulationSystem *pop =
NpcPopulationSystem::getInstance();
char buf[256];
if (!pop) {
snprintf(buf, sizeof(buf),
"Population system unavailable.");
} else {
std::string err;
int npcs = pop->generateAllAreas(&err);
if (npcs == 0 && !err.empty())
snprintf(buf, sizeof(buf),
"Population generation "
"failed: %s", err.c_str());
else
snprintf(buf, sizeof(buf),
"Generated %d NPCs across "
"%zu area(s).",
npcs, rg.townAreas.size());
}
s_townResult = buf;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Generate the deterministic NPC "
"population of every town area into the "
"character registry (runtime-only "
"entries; one household per building). "
"Replaces the previously generated "
"population.");
ImGui::Separator();
/* Phase 4.6: rebuild one building from the current