Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b0b53705 |
No files matched your search
@@ -461,3 +461,15 @@ Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_ve
|
||||
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
||||
sends `apply_player_action(BLOCKING)`.
|
||||
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
|
||||
|
||||
## Crowd presentation
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
|
||||
|
||||
|
||||
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
||||
|
||||
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
|
||||
incident (rubberneck). On `crowd_panic`, they move away from the door.
|
||||
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
|
||||
NPCs if frame time drops.
|
||||
@@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||
- Next work: `TODO.md` crowd presentation (rubberneck + panicked NPCs), then emergency aftermath
|
||||
(evacuation, unjustified-call investigation).
|
||||
- Next work: `TODO.md` emergency aftermath (evacuation, unjustified-call investigation), then
|
||||
body-camera replay timeline and NPC theater poses.
|
||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||
|
||||
## Current State
|
||||
@@ -69,11 +69,38 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
|
||||
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
|
||||
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
|
||||
- **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three
|
||||
stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which
|
||||
invalidates the path so `get_next_path_position()` keeps returning the current point — only set
|
||||
it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand
|
||||
at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was
|
||||
zero — compare horizontally. (3) `path[0]` is the old start point, so picking any point "far
|
||||
enough away" can pick the point behind you and oscillate — pick the first point that is
|
||||
**closer to the goal** than the current position. `_nav_try_frames`/`_move_direct` fallback is
|
||||
still there.
|
||||
- **Crowd reactions listen on the EventBus:** `CrowdSystem.apply_event()` emits
|
||||
`event_bus.crowd_changed`; its own `changed` signal is never emitted. `GameManager._connect_crowd()`
|
||||
therefore connects to `coordinator.event_bus.crowd_changed` (reconnect after every
|
||||
`coordinator.setup()`, which recreates both crowd and event bus).
|
||||
- **Crowd presentation:** `NpcAvatar.start_rubberneck(at)` (idle bystanders away from the door stop
|
||||
and face the incident for 12 s) and `start_flee(from)` (panic: walk away at 2.4 m/s, then free
|
||||
themselves). Someone `awaiting_interaction` within 3 m of the door is "engaged" and does not
|
||||
rubberneck. Use `position` (not `global_position`) for that distance check so it also works in
|
||||
headless tests outside the tree.
|
||||
- **Perf:** a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples),
|
||||
so LOD/culling is not warranted at `MAX_LIVE_NPCS = 16`. Re-measure before raising the cap.
|
||||
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
|
||||
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
|
||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||
before building the review.
|
||||
- **Skyline backdrop (2026-10-31):** `PlaneMesh` defaults to a **horizontal** XZ plane — set
|
||||
`mesh.orientation = PlaneMesh.FACE_Z` or the "backdrop" renders as a band overhead. The skyline
|
||||
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
|
||||
`TEXTURE_FILTER` default, `TRANSPARENCY_ALPHA_SCISSOR` with threshold **0.5** (0.3 left faint
|
||||
mip dashes across the sky), unshaded with a lifted albedo (`Color(2.6,2.7,3.0)`) so it is not
|
||||
pure black. Ground is 500x500 so no sky gap shows under the buildings. The `skyline.png` import
|
||||
needs `mipmaps/generate=true`.
|
||||
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
||||
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
||||
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||
|
||||
@@ -28,8 +28,8 @@ The central design goal is simple:
|
||||
|
||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||
|
||||
> **Latest: v0.2.3** — the PLAN verbs (eye contact, recognition as evidence, body camera,
|
||||
> blocking that stations the guard, request reason) and working incident recording.
|
||||
> **Latest: v0.2.4** — crowd presentation (rubbernecking and panic), the distant skyline ring,
|
||||
> working NPC navigation, and a refreshed screenshot gallery.
|
||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||
@@ -211,6 +211,9 @@ godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
||||
|
||||
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
|
||||
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||
|
||||
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
||||
godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||
```
|
||||
|
||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||
@@ -263,7 +266,7 @@ Implemented so far:
|
||||
- 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`
|
||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||
- Headless unit tests: **98 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)
|
||||
- Headless unit tests: **105 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)
|
||||
- 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
|
||||
- Control pass: focus cone on NPC heads with a billboarded gold ring 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`)
|
||||
@@ -272,10 +275,13 @@ Implemented so far:
|
||||
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
|
||||
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
|
||||
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
|
||||
- Crowd presentation: aggressive behavior and responder arrivals make idle bystanders stop and face the incident; panic sends people away from the door (and they leave the scene). A busy 16-NPC entrance measured ~1.2 ms/frame, so no LOD/culling was needed at the current cap
|
||||
- Distant skyline ring: four alpha-scissored planes at 200 m read as a hazy city on the horizon (the baked backdrop used to show as a black slab), with a larger ground plane so the buildings meet the street
|
||||
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
|
||||
- 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)
|
||||
|
||||
Not yet implemented: body-camera replay timeline, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||
Not yet implemented: body-camera replay timeline, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||
|
||||
### Smoke Test Checklist
|
||||
|
||||
@@ -302,9 +308,11 @@ Not yet implemented: body-camera replay timeline, crowd rubbernecking, emergency
|
||||
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
|
||||
22. `Block` moves the guard between the person and the door.
|
||||
23. The shift review reports the incident count, including interactions left unresolved.
|
||||
24. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
||||
25. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||
26. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu.
|
||||
24. Escalating on someone (or a responder arriving) makes idle bystanders stop and face the incident.
|
||||
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
|
||||
26. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
||||
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||
28. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
# TODO
|
||||
|
||||
Godot 4.3 prototype. Release **v0.2.3** is tagged; CI builds the installers and AppImage.
|
||||
Headless suite **98/98**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, and the windowed
|
||||
focus/radial smoke.
|
||||
Godot 4.3 prototype. Release **v0.2.4** is tagged; CI builds the installers and AppImage.
|
||||
Headless suite **105/105**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`,
|
||||
`smoke_crowd.gd`, and the windowed focus/radial smoke.
|
||||
|
||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||
|
||||
## Crowd presentation
|
||||
|
||||
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
||||
|
||||
- [ ] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
|
||||
incident (rubberneck). On `crowd_panic`, they move away from the door.
|
||||
- [ ] Profile a busy entrance with textured materials and props; add LOD or culling for distant
|
||||
NPCs if frame time drops.
|
||||
|
||||
## Emergency aftermath
|
||||
|
||||
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · B body camera · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug",
|
||||
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug",
|
||||
"radial.raise_hand": "Stop",
|
||||
"radial.eye_contact": "Look",
|
||||
"radial.point": "Point",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 953 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.2.3"
|
||||
config/version="0.2.4"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
|
||||
"event": event_type,
|
||||
})
|
||||
|
||||
## Presentation cue for the avatars after a crowd event (PLAN: Public Crowd
|
||||
## Reaction). "watch" = rubberneck at the incident, "flee" = move away from the
|
||||
## door, "" = no reaction. Pure so it is headless-testable.
|
||||
static func reaction_for_event(event_type: String) -> String:
|
||||
match event_type:
|
||||
"aggressive_behavior", "visible_weapon", "emergency_responder":
|
||||
return "watch"
|
||||
_:
|
||||
return ""
|
||||
|
||||
## True once the crowd is panicking hard enough that people should clear out.
|
||||
func should_flee() -> bool:
|
||||
return crowd_panic
|
||||
|
||||
func summary() -> Dictionary:
|
||||
return {
|
||||
"mood": mood,
|
||||
|
||||
@@ -9,6 +9,7 @@ const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const DoorState := preload("res://src/core/door_state.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
@@ -134,6 +135,7 @@ func _ready() -> void:
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||
_connect_crowd()
|
||||
|
||||
_build_ui()
|
||||
_last_phase = -1
|
||||
@@ -160,10 +162,13 @@ func _build_guard() -> GuardController:
|
||||
flashlight.name = "Flashlight"
|
||||
flashlight.visible = false
|
||||
flashlight.position = Vector3(0, -0.15, -0.4)
|
||||
flashlight.rotation_degrees = Vector3(-10, 0, 0)
|
||||
flashlight.spot_range = 18.0
|
||||
flashlight.spot_angle = 28.0
|
||||
flashlight.light_energy = 2.5
|
||||
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
||||
# front of the guard instead of the person or door being checked.
|
||||
flashlight.rotation_degrees = Vector3.ZERO
|
||||
flashlight.spot_range = 22.0
|
||||
flashlight.spot_angle = 30.0
|
||||
flashlight.light_energy = 4.0
|
||||
flashlight.spot_attenuation = 0.6
|
||||
head.add_child(flashlight)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
@@ -217,6 +222,7 @@ func _reset_shift_state() -> void:
|
||||
hud.set_body_camera(false)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
_connect_crowd()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
|
||||
@@ -481,6 +487,51 @@ func _on_responder_arrived(call: Dictionary) -> void:
|
||||
})
|
||||
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
||||
|
||||
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
|
||||
## mood/attention float; this turns its events into avatar behavior.
|
||||
func _connect_crowd() -> void:
|
||||
# CrowdSystem routes its updates through the EventBus (`event_bus.crowd_changed`);
|
||||
# its own `changed` signal is not emitted by apply_event().
|
||||
if coordinator == null or coordinator.event_bus == null:
|
||||
return
|
||||
if not coordinator.event_bus.crowd_changed.is_connected(_on_crowd_changed):
|
||||
coordinator.event_bus.crowd_changed.connect(_on_crowd_changed)
|
||||
|
||||
func _on_crowd_changed(payload: Dictionary) -> void:
|
||||
var event_type := str(payload.get("event", ""))
|
||||
var panic := bool(payload.get("panic", false))
|
||||
# Panic wins: people clear away from the entrance.
|
||||
if panic or event_type == "crowd_panic":
|
||||
_flee_crowd()
|
||||
return
|
||||
if CrowdSystem.reaction_for_event(event_type) == "watch":
|
||||
_rubberneck_crowd()
|
||||
|
||||
func _incident_point() -> Vector3:
|
||||
if targeted_npc is Node3D and is_instance_valid(targeted_npc):
|
||||
return (targeted_npc as Node3D).global_position
|
||||
if guard:
|
||||
return guard.global_position
|
||||
return Vector3(0, 0, 1.2)
|
||||
|
||||
## Idle bystanders stop and face the incident instead of walking on.
|
||||
func _rubberneck_crowd() -> void:
|
||||
var point := _incident_point()
|
||||
for npc in npcs:
|
||||
if not is_instance_valid(npc) or not npc.has_method("start_rubberneck"):
|
||||
continue
|
||||
if npc == targeted_npc:
|
||||
continue
|
||||
npc.start_rubberneck(point)
|
||||
|
||||
## Panic: everyone nearby moves away from the door.
|
||||
func _flee_crowd() -> void:
|
||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
for npc in npcs:
|
||||
if not is_instance_valid(npc) or not npc.has_method("start_flee"):
|
||||
continue
|
||||
npc.start_flee(door_pos)
|
||||
|
||||
func _responder_profile() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "responder"
|
||||
|
||||
@@ -17,6 +17,14 @@ var arrived := false
|
||||
var idle_s := 0.0
|
||||
var lifetime_s := 90.0
|
||||
var ephemeral := true
|
||||
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
|
||||
## incident, "flee" clears away from it, "" is normal navigation.
|
||||
var reaction_mode := ""
|
||||
var reaction_point := Vector3.ZERO
|
||||
var _reaction_timer := 0.0
|
||||
const RUBBERNECK_S := 12.0
|
||||
const FLEE_S := 20.0
|
||||
const FLEE_SPEED := 2.4
|
||||
var agent: NavigationAgent3D = null
|
||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||
var _nav_usable := false
|
||||
@@ -225,10 +233,78 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
||||
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
||||
func start_rubberneck(at: Vector3) -> bool:
|
||||
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
|
||||
return false
|
||||
# Someone actually waiting at the entrance is being dealt with, not a bystander.
|
||||
# Avatars live under the identity SceneRoot, so `position` matches world space
|
||||
# and also works before the node enters the tree (tests).
|
||||
if awaiting_interaction and (position - door_position).length() < 3.0:
|
||||
return false
|
||||
if reaction_mode == "flee":
|
||||
return false
|
||||
reaction_mode = "watch"
|
||||
reaction_point = at
|
||||
_reaction_timer = RUBBERNECK_S
|
||||
velocity = Vector3.ZERO
|
||||
return true
|
||||
|
||||
## Panic: clear away from the incident (usually the door) in a straight line.
|
||||
func start_flee(from: Vector3) -> void:
|
||||
if npc.leave_requested or leave_requested_by_lifetime:
|
||||
return
|
||||
reaction_mode = "flee"
|
||||
reaction_point = from
|
||||
_reaction_timer = FLEE_S
|
||||
var away := global_position - from
|
||||
away.y = 0.0
|
||||
if away.length() < 0.5:
|
||||
away = Vector3(0.0, 0.0, 1.0)
|
||||
target_position = global_position + away.normalized() * 25.0
|
||||
target_position.y = 0.5
|
||||
arrived = false
|
||||
awaiting_interaction = false
|
||||
|
||||
func reaction_is_active() -> bool:
|
||||
return reaction_mode != ""
|
||||
|
||||
func _tick_reaction(delta: float) -> void:
|
||||
if reaction_mode == "":
|
||||
return
|
||||
_reaction_timer -= delta
|
||||
if reaction_mode == "flee":
|
||||
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
|
||||
reaction_mode = ""
|
||||
queue_free()
|
||||
return
|
||||
if _reaction_timer <= 0.0:
|
||||
reaction_mode = ""
|
||||
|
||||
## Turn the body toward the reaction point without moving.
|
||||
func _face_reaction_point(delta: float) -> void:
|
||||
var to_point := reaction_point - global_position
|
||||
to_point.y = 0.0
|
||||
if to_point.length() < 0.1:
|
||||
return
|
||||
var target_yaw := atan2(-to_point.x, -to_point.z)
|
||||
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_advance_fsm(delta)
|
||||
_tick_reaction(delta)
|
||||
if reaction_mode == "watch":
|
||||
velocity = Vector3.ZERO
|
||||
_face_reaction_point(delta)
|
||||
_animate(delta)
|
||||
return
|
||||
if reaction_mode == "flee":
|
||||
_move_toward_target(delta)
|
||||
_animate(delta)
|
||||
return
|
||||
_move_toward_target(delta)
|
||||
_animate(delta)
|
||||
|
||||
@@ -264,7 +340,7 @@ func _animate(delta: float) -> void:
|
||||
func _advance_fsm(delta: float) -> void:
|
||||
# Routine traffic despawns after a while once it has finished its walk, so
|
||||
# the crowd cap is not permanently occupied by idle standers.
|
||||
if ephemeral and arrived and npc.fsm.state != "LEAVING":
|
||||
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
||||
idle_s += delta
|
||||
if idle_s >= lifetime_s:
|
||||
leave_requested_by_lifetime = true
|
||||
@@ -291,7 +367,11 @@ func _move_toward_target(delta: float) -> void:
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
return
|
||||
agent.target_position = target_position
|
||||
# Re-assigning the target every physics frame invalidates the path, so
|
||||
# get_next_path_position() keeps returning the current position and the
|
||||
# avatar never moves. Only update the agent when the target changes.
|
||||
if agent.target_position != target_position:
|
||||
agent.target_position = target_position
|
||||
if agent.is_navigation_finished():
|
||||
if (global_position - target_position).length() < 3.0:
|
||||
_arrive()
|
||||
@@ -308,14 +388,32 @@ func _move_toward_target(delta: float) -> void:
|
||||
_move_direct(delta)
|
||||
return
|
||||
var next := agent.get_next_path_position()
|
||||
# Godot reports the current position as the first path point (on the navmesh
|
||||
# plane, so below the body) and only advances past it once the agent is within
|
||||
# path_desired_distance. Steering at the point we are standing on gives a
|
||||
# zero-length direction, and steering at any point merely "far enough" away can
|
||||
# pick the old start behind us. Follow the first path point that is genuinely
|
||||
# closer to the destination than we are.
|
||||
var path := agent.get_current_navigation_path()
|
||||
if path.size() >= 2:
|
||||
var goal: Vector3 = path[path.size() - 1]
|
||||
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
|
||||
for i in range(1, path.size()):
|
||||
var candidate: Vector3 = path[i]
|
||||
candidate.y = global_position.y
|
||||
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
|
||||
if candidate_to_goal < here_to_goal - 0.05:
|
||||
next = candidate
|
||||
break
|
||||
var dir := (next - global_position)
|
||||
dir.y = 0.0
|
||||
if dir.length() < 0.2:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
dir = dir.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
@@ -329,8 +427,9 @@ func _move_direct(delta: float) -> void:
|
||||
_arrive()
|
||||
return
|
||||
var dir := to_target.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
@@ -57,9 +57,11 @@ func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_sh
|
||||
func _build_ground() -> void:
|
||||
var ground := StaticBody3D.new()
|
||||
ground.name = "Ground"
|
||||
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
||||
# Large enough that the ground meets the distant skyline ring (no gap of sky
|
||||
# under the buildings when looking down the boulevard).
|
||||
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(160, 1, 160)
|
||||
col.size = Vector3(500, 1, 500)
|
||||
var shape_owner := ground.create_shape_owner(ground)
|
||||
ground.shape_owner_add_shape(shape_owner, col)
|
||||
root.add_child(ground)
|
||||
@@ -165,29 +167,49 @@ func _build_navmesh() -> void:
|
||||
root.add_child(region)
|
||||
|
||||
func _build_skyline_backdrop() -> void:
|
||||
## Skyline silhouette backdrop behind the building. The texture has a
|
||||
## transparent sky, so only the building silhouettes show; the material must
|
||||
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO).
|
||||
## Distant skyline surrounding the block. The texture's sky is transparent
|
||||
## (alpha scissor), so only building silhouettes show. Four planes form a
|
||||
## ring: a single plane placed behind the building reads as a floating black
|
||||
## band from the sidewalk.
|
||||
var backdrop := Node3D.new()
|
||||
backdrop.name = "SkylineBackdrop"
|
||||
var plane := MeshInstance3D.new()
|
||||
var mesh := PlaneMesh.new()
|
||||
mesh.size = Vector2(240, 60) # wide, low horizon
|
||||
plane.mesh = mesh
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_texture = T_SKYLINE
|
||||
mat.texture_repeat = true
|
||||
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
|
||||
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
mat.alpha_scissor_threshold = 0.3
|
||||
mat.alpha_scissor_threshold = 0.5
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
plane.material_override = mat
|
||||
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
plane.position = Vector3(0, 18, -60) # behind and above the building
|
||||
backdrop.add_child(plane)
|
||||
root.add_child(backdrop)
|
||||
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
|
||||
# distant skyline sits closer to a hazy daytime city.
|
||||
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
|
||||
_skyline_mat = mat
|
||||
var mesh := PlaneMesh.new()
|
||||
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
|
||||
mesh.orientation = PlaneMesh.FACE_Z
|
||||
mesh.size = Vector2(400, 60)
|
||||
# Far enough that the skyline reads as background and its top edge stays out
|
||||
# of the middle of the sky.
|
||||
const DIST := 200.0
|
||||
# The texture keeps its buildings in the lower part of the image, so the plane
|
||||
# center sits at half its height: the building bases meet the ground.
|
||||
const CENTER_Y := 30.0
|
||||
var placements := [
|
||||
[Vector3(0, CENTER_Y, -DIST), 0.0],
|
||||
[Vector3(0, CENTER_Y, DIST), 0.0],
|
||||
[Vector3(-DIST, CENTER_Y, 0), PI / 2.0],
|
||||
[Vector3(DIST, CENTER_Y, 0), PI / 2.0],
|
||||
]
|
||||
for placement in placements:
|
||||
var plane := MeshInstance3D.new()
|
||||
plane.mesh = mesh
|
||||
plane.material_override = mat
|
||||
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
plane.position = placement[0]
|
||||
plane.rotation.y = placement[1]
|
||||
backdrop.add_child(plane)
|
||||
root.add_child(backdrop)
|
||||
|
||||
var _skyline_mat: StandardMaterial3D = null
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func _process(_delta: float) -> bool:
|
||||
quit(1)
|
||||
return true
|
||||
if gm != null and not camera_staged:
|
||||
# The game boots into the venue menu now; start the Hollywood shift first.
|
||||
var menu = gm.get("start_menu")
|
||||
if menu != null and menu.visible:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
gm.guard.global_position = Vector3(0, 0.1, 10.5)
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
@@ -52,7 +56,8 @@ func _advance() -> void:
|
||||
state = 1
|
||||
state_frames = 0
|
||||
1:
|
||||
# Wait for a nearby NPC so the interaction HUD has a target.
|
||||
# Wait for a nearby NPC so the interaction HUD has a target. NPCs now
|
||||
# walk in from the street, so this can take a few simulated seconds.
|
||||
if gm != null and gm.npcs.size() > 0:
|
||||
gm.call("_cycle_nearby_target")
|
||||
_face_target()
|
||||
@@ -83,24 +88,27 @@ func _advance() -> void:
|
||||
# Close the radio form, then jump the shift to the LATE phase.
|
||||
if gm != null:
|
||||
gm.radio_panel.hide()
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
gm.hud.set_action_hint("")
|
||||
gm.coordinator.elapsed_s = 750.0
|
||||
state = 5
|
||||
state_frames = 0
|
||||
5:
|
||||
# Let the phase transition (and street lamps / marquee) settle, then
|
||||
# take the night avenue shot.
|
||||
# Stage the avenue view down the boulevard (not into the wall) and let
|
||||
# the night lighting and street lamps settle.
|
||||
if state_frames == 1 and gm != null:
|
||||
gm.guard.rotation.y = deg_to_rad(-70)
|
||||
gm.guard.head.rotation.x = deg_to_rad(3.0)
|
||||
if state_frames >= 90:
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
_shoot("05-late-night-avenue.png")
|
||||
if gm != null:
|
||||
gm.call("_toggle_flashlight")
|
||||
state = 6
|
||||
state_frames = 0
|
||||
6:
|
||||
if state_frames >= 40:
|
||||
# Face the entrance again and switch the flashlight on for the night set.
|
||||
if state_frames == 1 and gm != null:
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(4.0)
|
||||
gm.call("_toggle_flashlight")
|
||||
if state_frames >= 30:
|
||||
_shoot("06-night-flashlight.png")
|
||||
state = 7
|
||||
quit(0)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
extends SceneTree
|
||||
## Crowd-presentation smoke: starts a shift, waits for bystanders to settle, then
|
||||
## checks that an aggressive crowd event makes idle NPCs stop and watch, and that
|
||||
## panic sends them away from the door.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||
## Exit code 0 = pass, 1 = failure.
|
||||
|
||||
const MAX_FRAMES := 2400
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var phase := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
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")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout before crowd reactions were observed")
|
||||
return true
|
||||
match phase:
|
||||
0:
|
||||
if frame < 300:
|
||||
return false
|
||||
# Wait until at least one settled bystander is away from the door (someone
|
||||
# waiting at the entrance is being dealt with and must not rubberneck).
|
||||
if _bystander_count() == 0:
|
||||
return false
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
var watching := _count_mode("watch")
|
||||
_check(watching > 0, "aggressive behavior makes idle NPCs watch (%d watching)" % watching)
|
||||
phase = 1
|
||||
1:
|
||||
if frame < 320:
|
||||
return false
|
||||
var door: Vector3 = gm.selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
for npc in gm.npcs:
|
||||
if is_instance_valid(npc) and npc.has_method("reaction_is_active"):
|
||||
pass
|
||||
gm.coordinator.crowd.apply_event("visible_weapon")
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||
_check(gm.coordinator.crowd.should_flee(), "stacked alarms panic the crowd")
|
||||
var fleeing := _count_mode("flee")
|
||||
_check(fleeing > 0, "panic sends NPCs away from the door (%d fleeing)" % fleeing)
|
||||
var away_ok := true
|
||||
for npc in gm.npcs:
|
||||
if not is_instance_valid(npc) or npc.reaction_mode != "flee":
|
||||
continue
|
||||
if (npc.target_position - door).length() <= (npc.global_position - door).length():
|
||||
away_ok = false
|
||||
_check(away_ok, "every fleeing NPC targets a point farther from the door")
|
||||
phase = 2
|
||||
_:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _bystander_count() -> int:
|
||||
var n := 0
|
||||
for npc in gm.npcs:
|
||||
if not is_instance_valid(npc) or not npc.arrived:
|
||||
continue
|
||||
if npc.awaiting_interaction and npc.global_position.distance_to(npc.door_position) < 3.0:
|
||||
continue
|
||||
n += 1
|
||||
return n
|
||||
|
||||
func _settled_count() -> int:
|
||||
var n := 0
|
||||
for npc in gm.npcs:
|
||||
if is_instance_valid(npc) and npc.arrived:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
func _count_mode(mode: String) -> int:
|
||||
var n := 0
|
||||
for npc in gm.npcs:
|
||||
if is_instance_valid(npc) and npc.reaction_mode == mode:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
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:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: crowd rubberneck and panic flee")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,106 @@
|
||||
extends RefCounted
|
||||
## Crowd presentation: rubbernecking on aggressive/emergency events and clearing
|
||||
## out when the crowd panics (PLAN: Public Crowd Reaction).
|
||||
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func _profile() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "bystander"
|
||||
p.display_name = "Bystander"
|
||||
p.npc_type = Rules.NpcType.TOURIST
|
||||
return p
|
||||
|
||||
func _avatar() -> Node:
|
||||
var avatar: Node = NpcAvatar.new()
|
||||
avatar.setup(_profile(), "photo", 99, Vector3(4, 0.5, 8), Vector3(2, 0.5, 12))
|
||||
return avatar
|
||||
|
||||
func test_reaction_for_event() -> bool:
|
||||
var a := TestUtils.check_eq(CrowdSystem.reaction_for_event("aggressive_behavior"), "watch", "aggression draws watchers")
|
||||
var b := TestUtils.check_eq(CrowdSystem.reaction_for_event("emergency_responder"), "watch", "responder draws watchers")
|
||||
var c := TestUtils.check_eq(CrowdSystem.reaction_for_event("visible_weapon"), "watch", "a weapon draws watchers")
|
||||
var d := TestUtils.check_eq(CrowdSystem.reaction_for_event("de_escalation"), "", "de-escalation is not a watch cue")
|
||||
var e := TestUtils.check_eq(CrowdSystem.reaction_for_event("calm_professionalism"), "", "calm is not a watch cue")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_panic_flag_and_flee_cue() -> bool:
|
||||
var crowd := CrowdSystem.new()
|
||||
var a := TestUtils.check(not crowd.should_flee(), "crowd starts calm")
|
||||
crowd.apply_event("visible_weapon")
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
crowd.apply_event("emergency_responder")
|
||||
var b := TestUtils.check(crowd.should_flee(), "stacked alarms reach panic")
|
||||
var c := TestUtils.check_eq(crowd.summary().get("panic"), true, "summary reports panic")
|
||||
var calm := CrowdSystem.new()
|
||||
calm.apply_event("de_escalation")
|
||||
calm.apply_event("calm_professionalism")
|
||||
var d := TestUtils.check(not calm.should_flee(), "a calm crowd does not flee")
|
||||
crowd = null
|
||||
calm = null
|
||||
return a and b and c and d
|
||||
|
||||
func test_idle_bystander_rubbernecks() -> bool:
|
||||
var avatar := _avatar()
|
||||
# Still walking: should refuse so traffic is not frozen mid-approach.
|
||||
var walking := TestUtils.check(not avatar.start_rubberneck(Vector3(0, 1, 1)), "walkers keep walking")
|
||||
avatar.arrived = true
|
||||
var accepted := TestUtils.check(avatar.start_rubberneck(Vector3(0, 1, 1)), "idle bystander watches")
|
||||
var mode := TestUtils.check_eq(avatar.reaction_mode, "watch", "watch mode is set")
|
||||
var active := TestUtils.check(avatar.reaction_is_active(), "reaction is active")
|
||||
avatar.free()
|
||||
return walking and accepted and mode and active
|
||||
|
||||
func test_engaged_person_does_not_rubberneck() -> bool:
|
||||
var avatar := _avatar()
|
||||
avatar.arrived = true
|
||||
avatar.awaiting_interaction = true
|
||||
# Not in a tree, so use local position (global_position is a no-op here).
|
||||
avatar.position = avatar.door_position
|
||||
var ok := TestUtils.check(not avatar.start_rubberneck(Vector3.ZERO), "someone waiting at the door does not rubberneck")
|
||||
# The same person further away is an ordinary bystander and does watch.
|
||||
avatar.position = avatar.door_position + Vector3(8, 0, 0)
|
||||
var far_ok := TestUtils.check(avatar.start_rubberneck(Vector3.ZERO), "a bystander away from the door still watches")
|
||||
avatar.free()
|
||||
return ok and far_ok
|
||||
|
||||
func test_panic_moves_people_away_from_the_door() -> bool:
|
||||
var avatar := _avatar()
|
||||
avatar.position = Vector3(1, 0.5, 2)
|
||||
var door := Vector3(0, 0.5, 1.2)
|
||||
avatar.start_flee(door)
|
||||
var a := TestUtils.check_eq(avatar.reaction_mode, "flee", "flee mode is set")
|
||||
var b := TestUtils.check((avatar.target_position - door).length() > (avatar.position - door).length(),
|
||||
"flee target is farther from the door than the start")
|
||||
var c := TestUtils.check(not avatar.awaiting_interaction, "fleeing cancels waiting at the entrance")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_crowd_updates_arrive_through_the_event_bus() -> bool:
|
||||
## CrowdSystem.apply_event() emits EventBus.crowd_changed, not its own
|
||||
## `changed` signal; wiring the wrong one silently disables crowd reactions.
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var ok := TestUtils.check(src.contains("event_bus.crowd_changed.connect(_on_crowd_changed)"),
|
||||
"GameManager listens on EventBus.crowd_changed")
|
||||
var bus: Object = load("res://src/core/event_bus.gd").new()
|
||||
var seen: Array = []
|
||||
bus.crowd_changed.connect(func(payload): seen.append(payload.get("event", "")))
|
||||
var crowd := CrowdSystem.new()
|
||||
crowd.set_event_bus(bus)
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
var delivers := TestUtils.check_eq(seen, ["aggressive_behavior"], "apply_event reaches the bus listener")
|
||||
bus = null
|
||||
crowd = null
|
||||
return ok and delivers
|
||||
|
||||
func test_leaving_npc_does_not_flee() -> bool:
|
||||
var avatar := _avatar()
|
||||
avatar.npc.leave_requested = true
|
||||
avatar.start_flee(Vector3.ZERO)
|
||||
var ok := TestUtils.check_eq(avatar.reaction_mode, "", "someone already leaving is left alone")
|
||||
avatar.free()
|
||||
return ok
|
||||
@@ -221,7 +221,7 @@ def skyline():
|
||||
x = -40
|
||||
while x < w + 40:
|
||||
bw = rnd.randint(64, 170)
|
||||
bh = rnd.randint(150, 400)
|
||||
bh = rnd.randint(90, 165)
|
||||
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
|
||||
# few lit windows
|
||||
for _ in range(bw * bh // 900):
|
||||
|
||||