Give street props colliders, zoned placements and variants
Props were decoration: the guard and guests walked straight through benches, bins, bollards and planters. They now block movement and steer guests around. Collision - _add_prop_model() fits a StaticBody3D from the model's mesh bounds in holder-local space: a cylinder for bollard/bin/hydrant, a box otherwise, so placement rotation and variant scale carry over. - Fallback primitives go through the same fit, so a missing .glb cannot silently remove collision. - Each prop also publishes a NavigationObstacle3D; NpcAvatar enables agent avoidance and moves with the safe velocity from velocity_computed. The navmesh stays a flat walkable plane and avoidance does the steering. Placement - PROP_PLACEMENTS groups furniture by entrance, mid-block and curb zones, with a static placement_problems() validator for bounds, overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions. - variant_for(index) gives deterministic yaw jitter, subtle scale, and optional clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant). Verification - New windowed smoke_props.gd: guard walks into a bench and stops; a guest headed beyond the bench routes around it and never enters the collider. - Headless 182 -> 187: placement validation and its failure modes, variant determinism/range, collider + obstacle on every placed prop, fallback collider. - Other smokes (PLAN verbs, crowd, emergency, timeline) still pass; Python tooling 53/53; screenshots recaptured; compare 9 good / 0 bad. - Version 0.4.0. TODO: collision/placement section moved to DONE; prop pipeline checks remain.
No files matched your search
@@ -709,3 +709,28 @@ Python tooling **53/53**, headless **182/182**.
|
|||||||
the reference matches the game camera.
|
the reference matches the game camera.
|
||||||
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
|
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
|
||||||
keeps the real reaction lines.
|
keeps the real reaction lines.
|
||||||
|
|
||||||
|
## Prop Collision And Placement
|
||||||
|
|
||||||
|
Completed 2026-11-01 (v0.4.0). Headless **187/187**, Python tooling **53/53**, the new
|
||||||
|
`smoke_props.gd` passes windowed, and `screen_designs.py compare` still reports 9 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] Every generated prop now gets a fitted static collider: a cylinder for bollard, bin, and
|
||||||
|
hydrant, a box for everything else. The collider is computed from the model's mesh bounds in
|
||||||
|
holder-local space, so placement rotation and variant scale carry over.
|
||||||
|
- [x] Added `smoke_props.gd` (windowed): the guard walks into a bench and stops instead of passing
|
||||||
|
through it; a guest heading for a point beyond the same bench routes around it with
|
||||||
|
navigation avoidance and never ends up inside the collider.
|
||||||
|
- [x] Props publish a `NavigationObstacle3D` footprint, and `NpcAvatar` enables agent avoidance with
|
||||||
|
a `velocity_computed` callback. The navmesh stays a flat walkable plane; avoidance does the
|
||||||
|
steering around street furniture.
|
||||||
|
- [x] Replaced the hard-coded prop calls with `WorldBuilder.PROP_PLACEMENTS`, grouped by entrance,
|
||||||
|
mid-block, and curb zones, with a static `placement_problems()` validator for bounds,
|
||||||
|
overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions.
|
||||||
|
- [x] Deterministic variants per placement (`variant_for`): yaw jitter, subtle scale, and optional
|
||||||
|
procedural clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant), so
|
||||||
|
repeated props stop looking cloned.
|
||||||
|
- [x] Fallback primitive props go through the same bounds fit, so a missing `.glb` cannot silently
|
||||||
|
remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle.
|
||||||
|
- [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on
|
||||||
|
every placed prop (including obstacle presence), and the fallback collider path.
|
||||||
@@ -46,11 +46,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs
|
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs
|
||||||
`recorder.close_all_open()`, because `to_dict()` only lists closed incidents. The escalation
|
`recorder.close_all_open()`, because `to_dict()` only lists closed incidents. The escalation
|
||||||
HUD mockup is third-person now; `judge` still classes it `review` (no diff).
|
HUD mockup is third-person now; `judge` still classes it `review` (no diff).
|
||||||
- **Presentation pass planned (TODO):** three sections — props, NPC characters, player movement.
|
- **Prop collision (v0.4.0):** `_add_prop_model()` fits a `StaticBody3D` from the model bounds
|
||||||
Grounding facts: generated props currently have **no colliders** (only ground/building/steps do),
|
(cylinder for bollard/bin/hydrant, box otherwise) and adds a `NavigationObstacle3D`; NPC agents
|
||||||
prop placement is a fixed list in `_build_street_detail()`, the NPC rig is rigid capsules with a
|
enable avoidance and move with the safe velocity from `velocity_computed`. The navmesh stays a
|
||||||
whole-limb swing, and `guard_controller.gd` uses one `accel` value. Work through the sections in
|
flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in
|
||||||
order; each task should get its own windowed smoke or capture where it changes feel/looks.
|
`PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`.
|
||||||
|
`smoke_props.gd` must be windowed because the guard reads captured-mouse input.
|
||||||
|
- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the
|
||||||
|
NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel`
|
||||||
|
value. Each task should get its own windowed smoke or capture where it changes feel/looks.
|
||||||
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
||||||
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
|
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
|
||||||
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ The central design goal is simple:
|
|||||||
> Downloads are public (no sign-in required) since the host's instance-wide
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
>
|
>
|
||||||
> **Latest: v0.3.3** — staged captures for the wand panel, escalation HUD, and body-camera timeline now feed the visual checks.
|
> **Latest: v0.4.0** — props have colliders and navigation obstacles, with zoned placements and deterministic variants.
|
||||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
@@ -313,6 +313,9 @@ godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
|||||||
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
|
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
|
||||||
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||||
|
|
||||||
|
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
|
||||||
|
godot --path . --script res://src/test/smoke_props.gd
|
||||||
|
|
||||||
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
||||||
godot --headless --path . --script res://src/test/smoke_crowd.gd
|
godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||||
|
|
||||||
@@ -381,7 +384,7 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **182 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material)
|
- Headless unit tests: **187 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
@@ -404,6 +407,7 @@ Implemented so far:
|
|||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
|
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
|
||||||
|
- Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter
|
||||||
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
|
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
|
||||||
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
|
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
|
||||||
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
||||||
|
|||||||
@@ -1,44 +1,15 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Latest release **v0.3.3** (staged captures for the new screens). Headless
|
Godot 4.3 prototype. Latest release **v0.4.0** (prop collision, placement, variants). Headless
|
||||||
suite **182/182** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency,
|
suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency,
|
||||||
timeline, audio, scenario-content, and windowed focus/radial smokes pass. Anonymous downloads are
|
timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous
|
||||||
enabled on the host.
|
downloads are enabled on the host.
|
||||||
|
|
||||||
The three sections below are the planned presentation pass: props, NPC characters, and player
|
The sections below are the rest of the presentation pass: prop pipeline checks, NPC characters,
|
||||||
movement. Each task is meant to be a small, reviewable slice with its own verification.
|
and player movement. Each task is meant to be a small, reviewable slice with its own verification.
|
||||||
|
|
||||||
## Better Props
|
## Better Props
|
||||||
|
|
||||||
### Collision and placement
|
|
||||||
|
|
||||||
- [ ] Give every generated prop a real collider. Add a simple box/cylinder collision shape to the
|
|
||||||
`.glb` (or emit a `physics` block per part that `WorldBuilder` turns into a
|
|
||||||
`StaticBody3D`), so benches, bins, planters, hydrants, and newspaper boxes cannot be walked
|
|
||||||
through.
|
|
||||||
- [ ] Add a windowed check that the guard and NPCs path around props instead of through them, and
|
|
||||||
that the fallback primitive props get the same colliders as the modelled ones.
|
|
||||||
- [ ] Replace the hard-coded prop positions in `_build_street_detail()` with a placement table
|
|
||||||
(zone, offset, rotation, variant) so clusters read as street furniture: entrance pair,
|
|
||||||
curb line, mid-block, wall side. Assert no overlaps with the door, steps, or spawn lane.
|
|
||||||
- [ ] Reuse one `PackedScene` per prop type but expose deterministic variants (rotation, scale,
|
|
||||||
optional accessory such as a chain, sign, or bag) so repeated props stop looking cloned.
|
|
||||||
|
|
||||||
### New props and detail
|
|
||||||
|
|
||||||
- [ ] Add street-sign props: parking sign, no-parking sign, one-way sign, and a small post
|
|
||||||
(text via the atlas, not a texture per sign).
|
|
||||||
- [ ] Add a bus-stop shelter with a bench, roof, and side panel; a bike rack; a mailbox; and a
|
|
||||||
phone booth or newspaper stand.
|
|
||||||
- [ ] Add a traffic signal (pole, arm, three lenses) and a street lamp variant that matches the
|
|
||||||
lamps placed by `WorldBuilder`.
|
|
||||||
- [ ] Add cafe/shop clutter: sandwich board, stacked crates, café table + two chairs, and a tied
|
|
||||||
trash-bag pile for the Joke Shop edge.
|
|
||||||
- [ ] Detail the existing six props: chamfered edges on boxes, rim rings on bollard and bin,
|
|
||||||
bolts on the hydrant, bench slats with gaps, visible soil plus foliage on the planter.
|
|
||||||
- [ ] Add a close-range detail layer: pack a subtle grunge/AO strip into the atlas (or a shared
|
|
||||||
detail normal) so props do not read as flat-coloured boxes at conversation distance.
|
|
||||||
|
|
||||||
### Pipeline and checks
|
### Pipeline and checks
|
||||||
|
|
||||||
- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside
|
- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 836 KiB After Width: | Height: | Size: 979 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 526 KiB |
@@ -8,7 +8,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Step Line"
|
config/name="Step Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.3.3"
|
config/version="0.4.0"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
agent.target_desired_distance = 4.0
|
agent.target_desired_distance = 4.0
|
||||||
agent.radius = 0.35
|
agent.radius = 0.35
|
||||||
agent.height = 1.2
|
agent.height = 1.2
|
||||||
|
# Steer around street furniture instead of walking into it (props publish a
|
||||||
|
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
|
||||||
|
agent.avoidance_enabled = true
|
||||||
|
agent.neighbor_distance = 4.0
|
||||||
|
agent.max_neighbors = 8
|
||||||
|
agent.time_horizon_agents = 0.6
|
||||||
|
agent.time_horizon_obstacles = 1.2
|
||||||
|
agent.max_speed = FLEE_SPEED
|
||||||
|
agent.velocity_computed.connect(_on_safe_velocity)
|
||||||
add_child(agent)
|
add_child(agent)
|
||||||
|
|
||||||
# Normalize intent targets: approach the entrance, stop near the door.
|
# Normalize intent targets: approach the entrance, stop near the door.
|
||||||
@@ -98,6 +107,7 @@ var _sign_prop: MeshInstance3D = null
|
|||||||
var _legs: Array[MeshInstance3D] = []
|
var _legs: Array[MeshInstance3D] = []
|
||||||
var _arms: Array[MeshInstance3D] = []
|
var _arms: Array[MeshInstance3D] = []
|
||||||
var _theater_t := 0.0
|
var _theater_t := 0.0
|
||||||
|
var _avoidance_active := false
|
||||||
var _photo_prop_base_y := 0.0
|
var _photo_prop_base_y := 0.0
|
||||||
var _sign_prop_base_y := 0.0
|
var _sign_prop_base_y := 0.0
|
||||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||||
@@ -527,6 +537,7 @@ func _advance_fsm(delta: float) -> void:
|
|||||||
var leave_requested_by_lifetime := false
|
var leave_requested_by_lifetime := false
|
||||||
|
|
||||||
func _move_toward_target(delta: float) -> void:
|
func _move_toward_target(delta: float) -> void:
|
||||||
|
_avoidance_active = false
|
||||||
if agent == null:
|
if agent == null:
|
||||||
_move_direct(delta)
|
_move_direct(delta)
|
||||||
return
|
return
|
||||||
@@ -575,14 +586,33 @@ func _move_toward_target(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
dir = dir.normalized()
|
dir = dir.normalized()
|
||||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||||
|
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||||
|
if agent.avoidance_enabled:
|
||||||
|
# The navigation server returns the obstacle-aware velocity through
|
||||||
|
# velocity_computed; never dash straight at the point in the meantime.
|
||||||
|
_avoidance_active = true
|
||||||
|
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
|
||||||
|
else:
|
||||||
velocity.x = dir.x * speed
|
velocity.x = dir.x * speed
|
||||||
velocity.z = dir.z * speed
|
velocity.z = dir.z * speed
|
||||||
velocity.y -= 9.8 * delta
|
velocity.y -= 9.8 * delta
|
||||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
move_and_slide()
|
||||||
|
|
||||||
|
## Safe velocity from NavigationServer avoidance: move with the steered vector.
|
||||||
|
func _on_safe_velocity(safe: Vector3) -> void:
|
||||||
|
if not _avoidance_active:
|
||||||
|
return
|
||||||
|
velocity.x = safe.x
|
||||||
|
velocity.z = safe.z
|
||||||
|
if is_on_floor():
|
||||||
|
velocity.y = 0.0
|
||||||
|
else:
|
||||||
|
velocity.y -= 9.8 * get_physics_process_delta_time()
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
||||||
func _move_direct(delta: float) -> void:
|
func _move_direct(delta: float) -> void:
|
||||||
|
_avoidance_active = false
|
||||||
var to_target := target_position - global_position
|
var to_target := target_position - global_position
|
||||||
to_target.y = 0.0
|
to_target.y = 0.0
|
||||||
var dist := to_target.length()
|
var dist := to_target.length()
|
||||||
|
|||||||
@@ -335,35 +335,112 @@ func _build_street_furniture() -> void:
|
|||||||
tree.add_child(crown)
|
tree.add_child(crown)
|
||||||
root.add_child(tree)
|
root.add_child(tree)
|
||||||
|
|
||||||
|
## Street-furniture placement table. Positions are in world space on the
|
||||||
|
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
|
||||||
|
## furniture instead of a scattered list. `accessory` names a small procedural
|
||||||
|
## addition (bag, crate, cone) for one-off variation.
|
||||||
|
const PROP_PLACEMENTS := [
|
||||||
|
# Entrance: bollards flank the foot of the steps, bins catch people who
|
||||||
|
# stop at the door.
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
|
||||||
|
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
|
||||||
|
# Mid-block: benches face the boulevard, planters break up the long run.
|
||||||
|
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
|
||||||
|
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
|
||||||
|
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
|
||||||
|
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
|
||||||
|
# Curb: newspaper boxes and a hydrant along the roadway edge.
|
||||||
|
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
|
||||||
|
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
|
||||||
|
"accessory": "crate"},
|
||||||
|
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
|
||||||
|
"accessory": "cone"},
|
||||||
|
]
|
||||||
|
|
||||||
|
## The door, steps, and the lane guests walk in through stay clear of props.
|
||||||
|
const PROP_EXCLUSIONS := [
|
||||||
|
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
|
||||||
|
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
|
||||||
|
]
|
||||||
|
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
|
||||||
|
## Two prop centres closer than this read as one blob (newspaper boxes are
|
||||||
|
## deliberately lined up, so this stays below their 0.7 m spacing).
|
||||||
|
const PROP_MIN_SPACING := 0.6
|
||||||
|
|
||||||
|
static func _variant_seed(index: int) -> int:
|
||||||
|
return index * 7919 + 13
|
||||||
|
|
||||||
|
## Deterministic per-placement variation. The same index always gives the same
|
||||||
|
## rotation and scale, so the street is reproducible while repeats differ.
|
||||||
|
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = _variant_seed(index)
|
||||||
|
return {
|
||||||
|
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
|
||||||
|
"scale": rng.randf_range(0.96, 1.04),
|
||||||
|
"roll": rng.randf(),
|
||||||
|
}
|
||||||
|
|
||||||
|
## Static validation used by tests: reject duplicate, out-of-bounds, or
|
||||||
|
## overlapping placements, and any prop inside the door/steps/approach lane.
|
||||||
|
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
|
||||||
|
var problems: Array[String] = []
|
||||||
|
var seen := {}
|
||||||
|
for i in placements.size():
|
||||||
|
var entry: Dictionary = placements[i]
|
||||||
|
var prop := str(entry.get("prop", ""))
|
||||||
|
var x := float(entry.get("x", 0.0))
|
||||||
|
var z := float(entry.get("z", 0.0))
|
||||||
|
var key := "%s@%.2f,%.2f" % [prop, x, z]
|
||||||
|
if seen.has(key):
|
||||||
|
problems.append("duplicate placement %s" % key)
|
||||||
|
seen[key] = true
|
||||||
|
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
|
||||||
|
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
|
||||||
|
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
|
||||||
|
for zone in PROP_EXCLUSIONS:
|
||||||
|
if x > float(zone["x0"]) and x < float(zone["x1"]) \
|
||||||
|
and z > float(zone["z0"]) and z < float(zone["z1"]):
|
||||||
|
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
|
||||||
|
for j in range(i):
|
||||||
|
var other: Dictionary = placements[j]
|
||||||
|
var dx := x - float(other.get("x", 0.0))
|
||||||
|
var dz := z - float(other.get("z", 0.0))
|
||||||
|
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
|
||||||
|
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
|
||||||
|
prop, str(other.get("prop", "")), x, z])
|
||||||
|
return problems
|
||||||
|
|
||||||
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
|
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
|
||||||
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
|
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
|
||||||
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so
|
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
|
||||||
## the street looks the same on every run.
|
## variants are fixed, so the street looks the same on every run.
|
||||||
func _build_street_detail() -> void:
|
func _build_street_detail() -> void:
|
||||||
var detail := Node3D.new()
|
var detail := Node3D.new()
|
||||||
detail.name = "StreetDetail"
|
detail.name = "StreetDetail"
|
||||||
root.add_child(detail)
|
root.add_child(detail)
|
||||||
# Bollards flank the foot of the steps.
|
for index in PROP_PLACEMENTS.size():
|
||||||
for x in [-5.6, -4.2, 4.2, 5.6]:
|
var entry: Dictionary = PROP_PLACEMENTS[index]
|
||||||
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7))
|
var base_rot := float(entry.get("rot", 0.0))
|
||||||
# Benches face the boulevard.
|
var variant := variant_for(index, base_rot)
|
||||||
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0)
|
var holder := _add_prop_model(detail, str(entry["prop"]),
|
||||||
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0)
|
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
|
||||||
# Bins where people leave the steps.
|
float(variant["rotation"]), index)
|
||||||
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9))
|
holder.scale = Vector3.ONE * float(variant["scale"])
|
||||||
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9))
|
var accessory := str(entry.get("accessory", ""))
|
||||||
# Planters break up the long sidewalk run.
|
if accessory != "":
|
||||||
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8))
|
_add_prop_accessory(holder, accessory)
|
||||||
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
|
|
||||||
# Newspaper boxes and a hydrant along the curb.
|
|
||||||
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
|
|
||||||
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
|
|
||||||
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
|
|
||||||
|
|
||||||
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
||||||
## missing (fresh checkout before `godot --import`) so the world still builds.
|
## missing (fresh checkout before `godot --import`) so the world still builds.
|
||||||
|
## A static collider is fitted from the model bounds (cylinder for round props,
|
||||||
|
## box for everything else), so props block the guard and NPCs.
|
||||||
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||||
rot_y_deg: float = 0.0) -> Node3D:
|
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
|
||||||
var holder := Node3D.new()
|
var holder := Node3D.new()
|
||||||
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
|
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
|
||||||
holder.position = pos
|
holder.position = pos
|
||||||
@@ -373,11 +450,97 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
|||||||
holder.add_child(model)
|
holder.add_child(model)
|
||||||
loaded_models.append(prop)
|
loaded_models.append(prop)
|
||||||
else:
|
else:
|
||||||
holder.add_child(_fallback_prop(prop))
|
var fallback := _fallback_prop(prop)
|
||||||
|
holder.add_child(fallback)
|
||||||
fallback_props.append(prop)
|
fallback_props.append(prop)
|
||||||
|
model = fallback
|
||||||
parent.add_child(holder)
|
parent.add_child(holder)
|
||||||
|
_add_prop_collision(holder, prop, model)
|
||||||
return holder
|
return holder
|
||||||
|
|
||||||
|
## Fit a simple collider to the prop's meshes (in holder-local space, so the
|
||||||
|
## placement scale and rotation carry over).
|
||||||
|
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
|
||||||
|
var bounds := _model_bounds(model, holder)
|
||||||
|
if bounds.size == Vector3.ZERO:
|
||||||
|
return
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "Collision"
|
||||||
|
var collider := CollisionShape3D.new()
|
||||||
|
collider.name = "Shape"
|
||||||
|
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
|
||||||
|
if prop in round_props:
|
||||||
|
var cylinder := CylinderShape3D.new()
|
||||||
|
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
|
||||||
|
cylinder.height = bounds.size.y
|
||||||
|
collider.shape = cylinder
|
||||||
|
else:
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = bounds.size
|
||||||
|
collider.shape = box
|
||||||
|
collider.position = bounds.get_center()
|
||||||
|
body.add_child(collider)
|
||||||
|
holder.add_child(body)
|
||||||
|
# Publish the footprint to NavigationServer so guest avoidance steers around
|
||||||
|
# the prop even though the navmesh itself is a flat walkable plane.
|
||||||
|
var obstacle := NavigationObstacle3D.new()
|
||||||
|
obstacle.name = "Avoidance"
|
||||||
|
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
|
||||||
|
obstacle.height = maxf(bounds.size.y, 0.5)
|
||||||
|
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
|
||||||
|
holder.add_child(obstacle)
|
||||||
|
|
||||||
|
## AABB of every mesh under `model`, expressed relative to `ancestor`.
|
||||||
|
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
|
||||||
|
var bounds := AABB()
|
||||||
|
var found := false
|
||||||
|
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var mi := node as MeshInstance3D
|
||||||
|
if mi.mesh == null:
|
||||||
|
continue
|
||||||
|
var xform := _transform_to_ancestor(mi, ancestor)
|
||||||
|
var box: AABB = xform * mi.mesh.get_aabb()
|
||||||
|
bounds = box if not found else bounds.merge(box)
|
||||||
|
found = true
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
|
||||||
|
var xform := Transform3D.IDENTITY
|
||||||
|
var current := node
|
||||||
|
while current != null and current != ancestor:
|
||||||
|
if current is Node3D:
|
||||||
|
xform = (current as Node3D).transform * xform
|
||||||
|
current = current.get_parent()
|
||||||
|
return xform
|
||||||
|
|
||||||
|
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
|
||||||
|
## newspaper boxes, a cone by the hydrant.
|
||||||
|
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
|
||||||
|
match kind:
|
||||||
|
"bag":
|
||||||
|
var bag := MeshInstance3D.new()
|
||||||
|
var sphere := SphereMesh.new()
|
||||||
|
sphere.radius = 0.22
|
||||||
|
sphere.height = 0.5
|
||||||
|
bag.mesh = sphere
|
||||||
|
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
|
||||||
|
bag.position = Vector3(0.38, 0.22, 0.06)
|
||||||
|
holder.add_child(bag)
|
||||||
|
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
|
||||||
|
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
|
||||||
|
"crate":
|
||||||
|
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
|
||||||
|
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
|
||||||
|
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
|
||||||
|
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
|
||||||
|
"cone":
|
||||||
|
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
|
||||||
|
Color(0.95, 0.36, 0.10), "painted_metal")
|
||||||
|
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
|
||||||
|
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
|
||||||
|
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
|
||||||
|
Color(0.92, 0.92, 0.9), "painted_metal")
|
||||||
|
|
||||||
func _load_prop_scene(prop: String) -> Node3D:
|
func _load_prop_scene(prop: String) -> Node3D:
|
||||||
var path := MODEL_DIR + prop + ".glb"
|
var path := MODEL_DIR + prop + ".glb"
|
||||||
if not ResourceLoader.exists(path):
|
if not ResourceLoader.exists(path):
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Prop-collision smoke: proves the guard and an NPC cannot walk through street
|
||||||
|
## furniture, and that a guest slides around a bench instead of passing through
|
||||||
|
## it. Windowed (the guard reads captured-mouse input):
|
||||||
|
##
|
||||||
|
## godot --path . --script res://src/test/smoke_props.gd
|
||||||
|
##
|
||||||
|
## Exit code 0 = pass, 1 = failure.
|
||||||
|
|
||||||
|
const MAX_FRAMES := 1800
|
||||||
|
const GUARD_PUSH_FRAMES := 100
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var step_frame := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
var bench: Node3D = null
|
||||||
|
var bench_center := Vector3.ZERO
|
||||||
|
var bench_body: StaticBody3D = null
|
||||||
|
var guard_start := Vector3.ZERO
|
||||||
|
var npc: Node3D = null
|
||||||
|
var npc_start := Vector3.ZERO
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout before the collision checks finished")
|
||||||
|
return true
|
||||||
|
step_frame += 1
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
_find_bench()
|
||||||
|
if bench == null:
|
||||||
|
return false
|
||||||
|
# Stand a couple of metres in front of the bench and walk into it.
|
||||||
|
var start := bench_center + Vector3(0, 0, 2.2)
|
||||||
|
start.y = 0.1
|
||||||
|
gm.guard.global_position = start
|
||||||
|
# Face the bench (-z): with the guard's basis, forward input at yaw PI
|
||||||
|
# walks toward lower z.
|
||||||
|
gm.guard.rotation.y = PI
|
||||||
|
guard_start = start
|
||||||
|
Input.action_press("move_forward")
|
||||||
|
step = 1
|
||||||
|
step_frame = 0
|
||||||
|
1:
|
||||||
|
if step_frame >= GUARD_PUSH_FRAMES:
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
_check(guard_start.z - gm.guard.global_position.z > 0.5,
|
||||||
|
"guard walked toward the bench")
|
||||||
|
_check(gm.guard.global_position.z > bench_center.z + 0.25,
|
||||||
|
"guard stopped at the bench instead of passing through it")
|
||||||
|
_check(not _inside_bench(gm.guard.global_position),
|
||||||
|
"guard is not inside the bench collider")
|
||||||
|
# Step the guard aside so the guest test hits the bench, not the player.
|
||||||
|
gm.guard.global_position = bench_center + Vector3(4.0, -0.08, 2.2)
|
||||||
|
_stage_npc()
|
||||||
|
step = 2
|
||||||
|
step_frame = 0
|
||||||
|
2:
|
||||||
|
if step_frame % 120 == 0 and npc != null and is_instance_valid(npc):
|
||||||
|
print("npc dbg pos=%s vel=%s arrived=%s wait=%s" % [str(npc.global_position), str(npc.velocity), str(npc.arrived), str(npc.awaiting_interaction)])
|
||||||
|
if npc == null or not is_instance_valid(npc):
|
||||||
|
_stage_npc()
|
||||||
|
return false
|
||||||
|
if _npc_finished():
|
||||||
|
_check(npc.global_position.z < bench_center.z - 0.4,
|
||||||
|
"guest reached the far side of the bench")
|
||||||
|
_check(not _inside_bench(npc.global_position),
|
||||||
|
"guest is not inside the bench collider")
|
||||||
|
_check(npc_start.distance_to(npc.global_position) > 1.0,
|
||||||
|
"guest moved while routing around the prop")
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _find_bench() -> void:
|
||||||
|
var detail := gm.get_node_or_null("World/StreetDetail")
|
||||||
|
if detail == null:
|
||||||
|
# The world root name differs between scene builds; search instead.
|
||||||
|
detail = root.find_child("StreetDetail", true, false)
|
||||||
|
if detail == null:
|
||||||
|
_fail("StreetDetail not found")
|
||||||
|
return
|
||||||
|
for child in detail.get_children():
|
||||||
|
if str(child.name).begins_with("Prop_bench"):
|
||||||
|
bench = child as Node3D
|
||||||
|
break
|
||||||
|
if bench == null:
|
||||||
|
_fail("no bench prop placed")
|
||||||
|
return
|
||||||
|
bench_center = bench.global_position
|
||||||
|
bench_body = bench.get_node_or_null("Collision") as StaticBody3D
|
||||||
|
if bench_body == null:
|
||||||
|
_fail("bench has no static collider")
|
||||||
|
|
||||||
|
func _stage_npc() -> void:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
step = 0
|
||||||
|
step_frame = 0
|
||||||
|
return
|
||||||
|
npc = gm.npcs[0]
|
||||||
|
npc_start = bench_center + Vector3(0, 0.5, 2.5)
|
||||||
|
npc.global_position = npc_start
|
||||||
|
npc.velocity = Vector3.ZERO
|
||||||
|
npc.arrived = false
|
||||||
|
npc.awaiting_interaction = false
|
||||||
|
npc.lifetime_s = 999.0
|
||||||
|
npc.reaction_mode = ""
|
||||||
|
# Target well past the bench forces a straight line through it, and stays
|
||||||
|
# beyond the controller's 3 m "arrived" shortcut so the guest must route.
|
||||||
|
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0)
|
||||||
|
|
||||||
|
func _npc_finished() -> bool:
|
||||||
|
if npc == null or not is_instance_valid(npc):
|
||||||
|
return false
|
||||||
|
return npc.global_position.z < bench_center.z - 0.4 \
|
||||||
|
or npc.global_position.distance_to(npc.target_position) < 0.9
|
||||||
|
|
||||||
|
func _inside_bench(point: Vector3) -> bool:
|
||||||
|
if bench_body == null:
|
||||||
|
return false
|
||||||
|
var collider := bench_body.get_node_or_null("Shape") as CollisionShape3D
|
||||||
|
if collider == null or not (collider.shape is BoxShape3D):
|
||||||
|
return false
|
||||||
|
var box := collider.shape as BoxShape3D
|
||||||
|
var half := box.size * 0.5
|
||||||
|
var center := collider.global_position
|
||||||
|
half.x += 0.1
|
||||||
|
half.z += 0.1
|
||||||
|
return absf(point.x - center.x) < half.x and absf(point.z - center.z) < half.z
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: props block the guard and guests route around them")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -83,6 +83,96 @@ func test_world_places_generated_props() -> bool:
|
|||||||
root.free()
|
root.free()
|
||||||
return a and b and c and d
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_prop_placements_are_valid() -> bool:
|
||||||
|
var problems := WorldBuilder.placement_problems()
|
||||||
|
return TestUtils.check(problems.is_empty(),
|
||||||
|
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_placement_validator_catches_problems() -> bool:
|
||||||
|
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
|
||||||
|
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
||||||
|
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
||||||
|
var problems := WorldBuilder.placement_problems(bad)
|
||||||
|
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
|
||||||
|
var joined := " ".join(problems)
|
||||||
|
var b := TestUtils.check(joined.contains("steps and door"),
|
||||||
|
"validator reports the door/steps exclusion")
|
||||||
|
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_prop_variants_are_deterministic() -> bool:
|
||||||
|
var first := WorldBuilder.variant_for(3, 180.0)
|
||||||
|
var second := WorldBuilder.variant_for(3, 180.0)
|
||||||
|
var other := WorldBuilder.variant_for(4, 180.0)
|
||||||
|
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
|
||||||
|
and is_equal_approx(first["scale"], second["scale"]),
|
||||||
|
"the same placement always gets the same variant")
|
||||||
|
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
|
||||||
|
"different placements get different rotations")
|
||||||
|
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
|
||||||
|
"variant scale stays subtle")
|
||||||
|
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
|
||||||
|
"variant rotation stays near the authored angle")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_every_prop_has_a_collider() -> bool:
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
builder.build()
|
||||||
|
var detail := root.get_node_or_null("StreetDetail")
|
||||||
|
var problems: Array[String] = []
|
||||||
|
if detail == null:
|
||||||
|
problems.append("no StreetDetail")
|
||||||
|
else:
|
||||||
|
for holder in detail.get_children():
|
||||||
|
var body := holder.get_node_or_null("Collision")
|
||||||
|
if body == null or not (body is StaticBody3D):
|
||||||
|
problems.append("%s has no StaticBody3D" % holder.name)
|
||||||
|
continue
|
||||||
|
var collider := body.get_node_or_null("Shape") as CollisionShape3D
|
||||||
|
if collider == null or collider.shape == null:
|
||||||
|
problems.append("%s has no collision shape" % holder.name)
|
||||||
|
continue
|
||||||
|
var size: Vector3
|
||||||
|
if collider.shape is BoxShape3D:
|
||||||
|
size = (collider.shape as BoxShape3D).size
|
||||||
|
elif collider.shape is CylinderShape3D:
|
||||||
|
var cylinder := collider.shape as CylinderShape3D
|
||||||
|
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
|
||||||
|
else:
|
||||||
|
problems.append("%s uses an unexpected shape" % holder.name)
|
||||||
|
continue
|
||||||
|
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
|
||||||
|
problems.append("%s has a degenerate collider" % holder.name)
|
||||||
|
if collider.position.y + size.y * 0.5 <= 0.05:
|
||||||
|
problems.append("%s collider sits below the ground" % holder.name)
|
||||||
|
if holder.get_node_or_null("Avoidance") == null:
|
||||||
|
problems.append("%s has no navigation obstacle" % holder.name)
|
||||||
|
root.free()
|
||||||
|
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_fallback_props_get_the_same_collider() -> bool:
|
||||||
|
## A missing `.glb` must not silently remove collision: fallback primitives go
|
||||||
|
## through the same bounds fit as the imported models.
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
var holder := Node3D.new()
|
||||||
|
var fallback := builder._fallback_prop("bench")
|
||||||
|
holder.add_child(fallback)
|
||||||
|
builder._add_prop_collision(holder, "bench", fallback)
|
||||||
|
var body := holder.get_node_or_null("Collision") as StaticBody3D
|
||||||
|
var a := TestUtils.check(body != null, "fallback bench gets a static body")
|
||||||
|
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
|
||||||
|
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
|
||||||
|
"fallback bench gets a box shape")
|
||||||
|
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
|
||||||
|
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
|
||||||
|
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
|
||||||
|
"fallback bench publishes a navigation obstacle")
|
||||||
|
holder.free()
|
||||||
|
root.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
func test_world_facade_windows_light_at_night() -> bool:
|
func test_world_facade_windows_light_at_night() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||