Add NPC theater poses: sit, photo, film, perform
Once an NPC reaches its intent waypoint it now does something instead of standing in an idle T-pose: - sit: the rig settles 0.34 m into a seat and the legs swing forward on the steps - photo/film: influencers and tourists raise their camera prop briefly (a sine pulse, so it reads as taking a shot rather than a permanently raised arm) - perform: performers sway the sign board they carry Pose selection lives in NpcAvatar.theater_pose() (intent + arrived); the math is in pure helpers (crouch_for_pose, prop_lift_for_pose, sway_for_pose) so it is headless-testable. Prop/leg nodes are captured at build time and animated from their rest offsets. Performers also get a hand-held board and post. Tests/docs - Headless suite 120 -> 127 (test_npc_theater.gd). - Verified visually with a staged sit/photo/perform lineup in front of the entrance. - README, TODO section moved to DONE, MEMORY. Version 0.2.7.
This commit is contained in:
1 parent
1b976bf351
commit
b69a7e6146
9 files changed
+221
-31
No files matched your search
@@ -494,3 +494,14 @@ Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timel
|
|||||||
|
|
||||||
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
||||||
Scrub or step through; no live 3D replay required in the first version.
|
Scrub or step through; no live 3D replay required in the first version.
|
||||||
|
|
||||||
|
## NPC theater
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
|
||||||
|
|
||||||
|
|
||||||
|
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
||||||
|
is in the working tree. Bodies still idle-stand at those spots.
|
||||||
|
|
||||||
|
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||||
|
stays put and plays the hat/sign.
|
||||||
@@ -21,7 +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.
|
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;
|
- `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.
|
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||||
- Next work: `TODO.md` NPC theater poses (sit/photo/perform), then audio and accessibility.
|
- Next work: `TODO.md` audio (crowd bed rising with mood, radio crackle), then accessibility and
|
||||||
|
scenario content.
|
||||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
@@ -94,6 +95,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||||
before building the review.
|
before building the review.
|
||||||
|
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
|
||||||
|
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
|
||||||
|
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
|
||||||
|
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
|
||||||
|
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
|
||||||
|
time so the animation offsets from rest.
|
||||||
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
|
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
|
||||||
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
|
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
|
||||||
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
|
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ The central design goal is simple:
|
|||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> **Latest: v0.2.6** — body-camera timeline in the shift review, stepping through every recorded
|
> **Latest: v0.2.7** — NPC theater: guests sit on the steps, tourists and influencers raise a
|
||||||
> incident event.
|
> camera, performers play their sign.
|
||||||
> 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
|
||||||
@@ -305,7 +305,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: **120 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)
|
- Headless unit tests: **127 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)
|
||||||
- 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 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`)
|
- 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`)
|
||||||
@@ -319,10 +319,11 @@ Implemented so far:
|
|||||||
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
|
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
|
||||||
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
|
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
|
||||||
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
|
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
|
||||||
|
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
||||||
- 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)
|
||||||
|
|
||||||
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
Not yet implemented: live 3D body-camera replay, ambience audio, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||||
|
|
||||||
### Smoke Test Checklist
|
### Smoke Test Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.6** is tagged. Headless suite **120/120**, plus
|
Godot 4.3 prototype. Release **v0.2.7** is tagged. Headless suite **127/127**, plus the session,
|
||||||
`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`,
|
PLAN-verb, crowd, emergency, timeline, and windowed focus/radial smokes.
|
||||||
`smoke_timeline.gd`, and the windowed focus/radial smoke.
|
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||||
|
|
||||||
## NPC theater
|
|
||||||
|
|
||||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
|
||||||
is in the working tree. Bodies still idle-stand at those spots.
|
|
||||||
|
|
||||||
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
|
||||||
stays put and plays the hat/sign.
|
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 1.3 MiB |
+1
-1
@@ -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.2.6"
|
config/version="0.2.7"
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
+80
-7
@@ -91,6 +91,13 @@ var _bob_phase := 0.0
|
|||||||
var _guard_ref: Node3D = null
|
var _guard_ref: Node3D = null
|
||||||
var _ring: MeshInstance3D = null
|
var _ring: MeshInstance3D = null
|
||||||
var _name_tag: Label3D = null
|
var _name_tag: Label3D = null
|
||||||
|
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
||||||
|
var _photo_prop: MeshInstance3D = null
|
||||||
|
var _sign_prop: MeshInstance3D = null
|
||||||
|
var _legs: Array[MeshInstance3D] = []
|
||||||
|
var _theater_t := 0.0
|
||||||
|
var _photo_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)
|
||||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||||
@@ -111,8 +118,8 @@ func _build_body(npc_type: int) -> void:
|
|||||||
# Torso
|
# Torso
|
||||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||||
# Legs
|
# Legs
|
||||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
||||||
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
||||||
# Arms
|
# Arms
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||||
@@ -170,7 +177,7 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
|||||||
if _name_tag:
|
if _name_tag:
|
||||||
_name_tag.visible = focused
|
_name_tag.visible = focused
|
||||||
|
|
||||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := CapsuleMesh.new()
|
var mesh := CapsuleMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
@@ -180,6 +187,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
|||||||
mi.rotation_degrees.z = z_rot
|
mi.rotation_degrees.z = z_rot
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
@@ -201,7 +209,8 @@ func _add_props(npc_type: int) -> void:
|
|||||||
var chest_y := 1.25
|
var chest_y := 1.25
|
||||||
match npc_type:
|
match npc_type:
|
||||||
Rules.NpcType.INFLUENCER:
|
Rules.NpcType.INFLUENCER:
|
||||||
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||||
|
_photo_prop_base_y = _photo_prop.position.y
|
||||||
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||||
Rules.NpcType.DELIVERY:
|
Rules.NpcType.DELIVERY:
|
||||||
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||||
@@ -216,15 +225,22 @@ func _add_props(npc_type: int) -> void:
|
|||||||
cone.material_override = hat
|
cone.material_override = hat
|
||||||
cone.position = Vector3(0, 0.26, 0)
|
cone.position = Vector3(0, 0.26, 0)
|
||||||
_head.add_child(cone)
|
_head.add_child(cone)
|
||||||
|
# Hat in one hand and a small board in the other: the performer plays
|
||||||
|
# to the sidewalk once at the waypoint.
|
||||||
|
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
|
||||||
|
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
|
||||||
|
_sign_prop_base_y = _sign_prop.position.y
|
||||||
Rules.NpcType.PROTESTER:
|
Rules.NpcType.PROTESTER:
|
||||||
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||||
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||||
|
_sign_prop_base_y = _sign_prop.position.y
|
||||||
Rules.NpcType.TOURIST:
|
Rules.NpcType.TOURIST:
|
||||||
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||||
|
_photo_prop_base_y = _photo_prop.position.y
|
||||||
Rules.NpcType.CONFUSED_VISITOR:
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||||
|
|
||||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := BoxMesh.new()
|
var mesh := BoxMesh.new()
|
||||||
mesh.size = size
|
mesh.size = size
|
||||||
@@ -232,6 +248,7 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
|||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
## 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.
|
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
||||||
@@ -309,14 +326,70 @@ func _physics_process(delta: float) -> void:
|
|||||||
_animate(delta)
|
_animate(delta)
|
||||||
|
|
||||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||||
|
## Theater pose for the current intent once the NPC has stopped walking.
|
||||||
|
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
|
||||||
|
## "film" for a slow pan, otherwise "idle".
|
||||||
|
func theater_pose() -> String:
|
||||||
|
if not arrived:
|
||||||
|
return "idle"
|
||||||
|
match npc.intent:
|
||||||
|
"sit":
|
||||||
|
return "sit"
|
||||||
|
"photo":
|
||||||
|
return "photo"
|
||||||
|
"film":
|
||||||
|
return "film"
|
||||||
|
"perform":
|
||||||
|
return "perform"
|
||||||
|
_:
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
## How far the rig drops for a seated pose. Pure so it is headless-testable.
|
||||||
|
static func crouch_for_pose(pose: String) -> float:
|
||||||
|
return -0.34 if pose == "sit" else 0.0
|
||||||
|
|
||||||
|
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
|
||||||
|
## it reads as taking a photo rather than a permanently raised arm.
|
||||||
|
static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
||||||
|
if pose != "photo" and pose != "film":
|
||||||
|
return 0.0
|
||||||
|
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
||||||
|
|
||||||
|
## Sway amount for a performer playing to the sidewalk.
|
||||||
|
static func sway_for_pose(pose: String, phase: float) -> float:
|
||||||
|
if pose != "perform":
|
||||||
|
return 0.0
|
||||||
|
return sin(phase * 2.2) * 0.09
|
||||||
|
|
||||||
func _animate(delta: float) -> void:
|
func _animate(delta: float) -> void:
|
||||||
if _rig == null:
|
if _rig == null:
|
||||||
return
|
return
|
||||||
var speed := Vector2(velocity.x, velocity.z).length()
|
var speed := Vector2(velocity.x, velocity.z).length()
|
||||||
|
var pose := theater_pose()
|
||||||
|
var crouch := crouch_for_pose(pose)
|
||||||
if speed > 0.3:
|
if speed > 0.3:
|
||||||
_bob_phase += delta * (6.0 + speed)
|
_bob_phase += delta * (6.0 + speed)
|
||||||
_rig.position.y = sin(_bob_phase) * 0.035
|
_rig.position.y = sin(_bob_phase) * 0.035
|
||||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||||
|
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
||||||
|
# Idle theater: seated, photographing, filming, or performing.
|
||||||
|
_theater_t += delta
|
||||||
|
_bob_phase = 0.0
|
||||||
|
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
|
||||||
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||||
|
var lift := prop_lift_for_pose(pose, _theater_t)
|
||||||
|
if _photo_prop:
|
||||||
|
_photo_prop.position.y = _photo_prop_base_y + lift
|
||||||
|
_photo_prop.rotation.x = -lift * 1.4
|
||||||
|
var sway := sway_for_pose(pose, _theater_t)
|
||||||
|
if _sign_prop:
|
||||||
|
_sign_prop.rotation.z = sway
|
||||||
|
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
|
||||||
|
if _legs.size() == 2:
|
||||||
|
# Negative pitch swings the legs forward (toward the way the body faces).
|
||||||
|
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
||||||
|
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
||||||
|
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
||||||
else:
|
else:
|
||||||
_bob_phase = 0.0
|
_bob_phase = 0.0
|
||||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## NPC theater: seated guests, camera raises, and performers playing to the
|
||||||
|
## sidewalk once their intent's waypoint is reached (PLAN.md: NPC Behavior Types).
|
||||||
|
|
||||||
|
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 _avatar(intent: String, npc_type: int = Rules.NpcType.TOURIST) -> Node:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "theater"
|
||||||
|
p.display_name = "Theater"
|
||||||
|
p.npc_type = npc_type
|
||||||
|
var avatar: Node = NpcAvatar.new()
|
||||||
|
avatar.setup(p, intent, 1234, Vector3(2, 0.5, 8), Vector3(0, 0.5, 6))
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
func test_pose_per_intent() -> bool:
|
||||||
|
var cases := {
|
||||||
|
"sit": "sit",
|
||||||
|
"photo": "photo",
|
||||||
|
"film": "film",
|
||||||
|
"perform": "perform",
|
||||||
|
"enter": "idle",
|
||||||
|
}
|
||||||
|
var ok := true
|
||||||
|
for intent in cases.keys():
|
||||||
|
var avatar := _avatar(str(intent))
|
||||||
|
avatar.arrived = true
|
||||||
|
if avatar.theater_pose() != cases[intent]:
|
||||||
|
ok = false
|
||||||
|
avatar.free()
|
||||||
|
return TestUtils.check(ok, "each intent maps to its theater pose once arrived")
|
||||||
|
|
||||||
|
func test_walking_npc_has_no_pose() -> bool:
|
||||||
|
var avatar := _avatar("sit")
|
||||||
|
var a := TestUtils.check_eq(avatar.theater_pose(), "idle", "a walking NPC does not sit yet")
|
||||||
|
avatar.arrived = true
|
||||||
|
var b := TestUtils.check_eq(avatar.theater_pose(), "sit", "arriving starts the pose")
|
||||||
|
avatar.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_only_sit_crouches() -> bool:
|
||||||
|
var a := TestUtils.check(is_equal_approx(NpcAvatar.crouch_for_pose("sit"), -0.34), "sit lowers the rig")
|
||||||
|
var b := TestUtils.check_eq(NpcAvatar.crouch_for_pose("photo"), 0.0, "photo keeps standing")
|
||||||
|
var c := TestUtils.check_eq(NpcAvatar.crouch_for_pose("perform"), 0.0, "perform keeps standing")
|
||||||
|
var d := TestUtils.check_eq(NpcAvatar.crouch_for_pose("idle"), 0.0, "idle keeps standing")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_photo_lift_is_brief() -> bool:
|
||||||
|
var raised := 0.0
|
||||||
|
var flat := 0.0
|
||||||
|
for i in 120:
|
||||||
|
var phase := float(i) / 60.0
|
||||||
|
var lift: float = NpcAvatar.prop_lift_for_pose("photo", phase)
|
||||||
|
raised = maxf(raised, lift)
|
||||||
|
if i == 0:
|
||||||
|
flat = lift
|
||||||
|
var a := TestUtils.check(raised > 0.1, "the camera prop lifts while photographing")
|
||||||
|
var b := TestUtils.check(raised <= 0.16 + 0.001, "the lift stays small")
|
||||||
|
var c := TestUtils.check_eq(flat, 0.0, "the lift starts at rest")
|
||||||
|
var d := TestUtils.check_eq(NpcAvatar.prop_lift_for_pose("sit", 0.5), 0.0, "seated guests do not lift a prop")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_performer_sways_only_when_performing() -> bool:
|
||||||
|
var peak := 0.0
|
||||||
|
for i in 120:
|
||||||
|
peak = maxf(peak, absf(NpcAvatar.sway_for_pose("perform", float(i) / 60.0)))
|
||||||
|
var a := TestUtils.check(peak > 0.05, "the performer's sign sways")
|
||||||
|
var b := TestUtils.check_eq(NpcAvatar.sway_for_pose("photo", 1.0), 0.0, "photographers do not sway")
|
||||||
|
var c := TestUtils.check_eq(NpcAvatar.sway_for_pose("sit", 1.0), 0.0, "seated guests do not sway")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_photo_prop_is_wired_for_camera_archetypes() -> bool:
|
||||||
|
var influencer := _avatar("photo", Rules.NpcType.INFLUENCER)
|
||||||
|
var tourist := _avatar("photo", Rules.NpcType.TOURIST)
|
||||||
|
var performer := _avatar("perform", Rules.NpcType.PERFORMER)
|
||||||
|
var a := TestUtils.check(influencer._photo_prop != null, "influencer carries a camera prop")
|
||||||
|
var b := TestUtils.check(tourist._photo_prop != null, "tourist carries a camera prop")
|
||||||
|
var c := TestUtils.check(performer._sign_prop != null, "performer carries a sign prop")
|
||||||
|
influencer.free()
|
||||||
|
tourist.free()
|
||||||
|
performer.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_sitting_rig_actually_lowers() -> bool:
|
||||||
|
var avatar := _avatar("sit")
|
||||||
|
avatar.arrived = true
|
||||||
|
for i in 40:
|
||||||
|
avatar._animate(0.016)
|
||||||
|
var a := TestUtils.check(avatar._rig.position.y < -0.2, "the rig settles into the crouch")
|
||||||
|
var legs_bent := TestUtils.check(absf(avatar._legs[0].rotation.x) > deg_to_rad(30.0), "legs bend for the seat")
|
||||||
|
avatar.arrived = false
|
||||||
|
for i in 60:
|
||||||
|
avatar._animate(0.016)
|
||||||
|
var b := TestUtils.check(avatar._rig.position.y > -0.1, "standing back up lifts the rig")
|
||||||
|
avatar.free()
|
||||||
|
return a and legs_bent and b
|
||||||
+16
-7
@@ -112,10 +112,10 @@ SCREENS = [
|
|||||||
"the ring, and the HUD shows a target line with the "
|
"the ring, and the HUD shows a target line with the "
|
||||||
"interaction hint.",
|
"interaction hint.",
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Realistic 3D first-person game scene: a pedestrian NPC walking "
|
"Realistic 3D first-person game scene: a friendly pedestrian "
|
||||||
"toward the camera on a city sidewalk, a thin pale-gold ring floating "
|
"standing on a city sidewalk, a thin pale-gold ring floating around "
|
||||||
"around the NPC's head as a selection marker, a small name label "
|
"their head as a game selection marker, a small name label under the "
|
||||||
"under the ring, minimal HUD target text at the bottom, daytime"),
|
"ring, minimal HUD target text at the bottom, daytime"),
|
||||||
"regions": [
|
"regions": [
|
||||||
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
||||||
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
||||||
@@ -156,9 +156,9 @@ SCREENS = [
|
|||||||
"whether anyone is in immediate danger, plus a send action.",
|
"whether anyone is in immediate danger, plus a send action.",
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Realistic 3D game screenshot with an in-game radio dispatch panel "
|
"Realistic 3D game screenshot with an in-game radio dispatch panel "
|
||||||
"overlaid: form fields for location, description, behaviour, weapons "
|
"overlaid: form fields for location, description, behaviour, a threat "
|
||||||
"seen and immediate danger, and a Send button, dark translucent panel "
|
"assessment and whether anyone is in danger, and a Send button, dark "
|
||||||
"over a softly blurred city street background"),
|
"translucent panel over a softly blurred city street background"),
|
||||||
"regions": [
|
"regions": [
|
||||||
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
||||||
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
||||||
@@ -407,13 +407,22 @@ def _generate_ai(screen, model, request_size):
|
|||||||
|
|
||||||
def generate(args):
|
def generate(args):
|
||||||
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
failed = []
|
||||||
for screen in SCREENS:
|
for screen in SCREENS:
|
||||||
print(f"generating {screen['name']} ...")
|
print(f"generating {screen['name']} ...")
|
||||||
|
try:
|
||||||
if args.draft:
|
if args.draft:
|
||||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||||
else:
|
else:
|
||||||
_generate_ai(screen, args.model, args.request_size)
|
_generate_ai(screen, args.model, args.request_size)
|
||||||
|
except SystemExit as exc:
|
||||||
|
failed.append(screen["name"])
|
||||||
|
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
||||||
print("done")
|
print("done")
|
||||||
|
if failed:
|
||||||
|
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user