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

+80 -7
View File
@@ -91,6 +91,13 @@ var _bob_phase := 0.0
var _guard_ref: Node3D = null
var _ring: MeshInstance3D = 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 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)]
@@ -111,8 +118,8 @@ func _build_body(npc_type: int) -> void:
# Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs
_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))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
# 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)
@@ -170,7 +177,7 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _name_tag:
_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 mesh := CapsuleMesh.new()
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.material_override = _material(color)
parent.add_child(mi)
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
var mi := MeshInstance3D.new()
@@ -201,7 +209,8 @@ func _add_props(npc_type: int) -> void:
var chest_y := 1.25
match npc_type:
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))
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))
@@ -216,15 +225,22 @@ func _add_props(npc_type: int) -> void:
cone.material_override = hat
cone.position = Vector3(0, 0.26, 0)
_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:
_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:
_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:
_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 mesh := BoxMesh.new()
mesh.size = size
@@ -232,6 +248,7 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
mi.position = pos
mi.material_override = _material(color)
parent.add_child(mi)
return 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.
@@ -309,14 +326,70 @@ func _physics_process(delta: float) -> void:
_animate(delta)
## 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:
if _rig == null:
return
var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose()
var crouch := crouch_for_pose(pose)
if speed > 0.3:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_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:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)