Add NPC theater poses: sit, photo, film, perform
Full build / Import + headless tests (push) Successful in 29s
Full build / Full desktop build + optional release (push) Failing after 3m44s

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:
zwitschi committed 2026-10-31 21:05:59 +01:00
1 parent 1b976bf351
commit e2c2a0bf4c
9 files changed
+225 -35

No files matched your search

+98
View File
@@ -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