Fix NPCs walking backwards

Avatars called Godot's look_at(), which points the node's -Z axis at the target,
but the rig's face (eyes/brows/mouth/focus disc, and the Blender successor) is on
+Z. Every walking NPC therefore faced away from its movement direction.

- add the pure yaw_towards(direction) helper (+Z forward) and use it in both
  movement paths and the reaction face-turn
- fix the head-turn offset to the same +Z convention (basis.z)
- test +Z/+X/-Z/arbitrary directions; recapture targeting screenshot
- TODO bug item moved to DONE, MEMORY records the convention

Headless 209/209.
This commit is contained in:
zwitschi committed 2026-11-01 12:30:41 +01:00
1 parent a7daae8a14
commit 0eb17e3bb4
6 files changed
+53 -9

No files matched your search

+16
View File
@@ -814,3 +814,19 @@ lookup, constants, gamepad bindings, and settings persistence.
deadzone, remap descriptor/apply/labels, and settings persistence for camera and bindings.
- [x] Added `smoke_movement.gd` (windowed) and `11-precision-walk.png` with a movement catalogue
entry, AI mockup, and compare coverage.
## NPC Facing During Movement
Completed 2026-11-01 (post-v0.5.0, pre-v0.6.0). Headless suite **209/209**.
- [x] Root cause: `NpcAvatar` used Godot's `look_at()`, which aims the node's **-Z** axis at
the target, but the procedural rig's face (eyes, brows, mouth, focus disc) and every
Blender-authored successor face **+Z**. Every walking NPC therefore showed its face
toward the side it was leaving.
- [x] Added the pure `NpcAvatar.yaw_towards(direction)` helper (+Z forward, tested at +Z/+X/-Z
and an arbitrary direction), and replaced `look_at()` in the navigation and direct-move
paths plus the reaction-face turn.
- [x] Corrected the head-turn offsets to use the same +Z convention (`basis.z` instead of
`-basis.z`), so noticed NPCs still look at the guard with the fixed facing.
- [x] Added `test_yaw_faces_the_movement_direction`; recaptured the targeting screenshot to
confirm NPCs now show their back only when walking away.
+6
View File
@@ -20,6 +20,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
- **NPC facing (fixed after v0.5.0):** the avatar's visual front is **+Z** (procedural eyes
and the Blender model's face). Godot `look_at()` aims **-Z**, which made every NPC walk
backwards. Use `NpcAvatar.yaw_towards(dir)` and set `rotation.y`; do not call `look_at()` on
avatars. Head-turn offsets use `+basis.z` for the same reason. Covered by
`test_yaw_faces_the_movement_direction`.
## Handoff (read this first)
- **Phase 2 — AI texturing (this agent, DONE 2026-11-01).** Phase 1 assets were
+1 -5
View File
@@ -1,17 +1,13 @@
# TODO
Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless
suite **208/208** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency,
suite **209/209** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency,
timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes
pass. Anonymous downloads are enabled on the host.
The scheduled presentation pass is complete (see `DONE.md`). The next scheduled pass is the
graphics-quality work in [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md).
## Bug Fix NPCs walking backwards
- [ ] Investigate and fix the issue causing NPCs to walk backwards during movement.
## Graphics quality — Blender-authored NPC characters (v0.6.0)
Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+13 -4
View File
@@ -252,6 +252,15 @@ static func queue_slot_for(index: int) -> Vector3:
]
return slots[clampi(index, 0, slots.size() - 1)]
## Yaw that makes the avatar's face (+Z, the procedural and Blender-front axis)
## point along `direction`. Godot's `look_at` aims -Z, which used to make every
## NPC walk backwards; keep the +Z convention in one tested helper.
static func yaw_towards(direction: Vector3) -> float:
var flat := Vector2(direction.x, direction.z)
if flat.length() < 0.001:
return 0.0
return atan2(flat.x, flat.y)
static func lod_tier_for_distance(distance: float) -> int:
if distance < LOD_TIERS[0]:
return 0
@@ -656,8 +665,8 @@ func _face_reaction_point(delta: float) -> void:
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))
rotation.y = lerp_angle(rotation.y, yaw_towards(to_point),
clampf(delta * 4.0, 0.0, 1.0))
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
@@ -1021,7 +1030,7 @@ func _move_toward_target(delta: float) -> void:
return
dir = dir.normalized()
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
rotation.y = yaw_towards(dir)
if agent.avoidance_enabled:
# The navigation server returns the obstacle-aware velocity through
# velocity_computed; never dash straight at the point in the meantime.
@@ -1059,7 +1068,7 @@ func _move_direct(delta: float) -> void:
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)
rotation.y = yaw_towards(dir)
move_and_slide()
func _arrive() -> void:
+17
View File
@@ -55,6 +55,23 @@ func test_idle_styles_are_known() -> bool:
ok = ok and style in allowed
return TestUtils.check(ok, "idle styles stay in the catalogue")
func test_yaw_faces_the_movement_direction() -> bool:
var a := TestUtils.check(is_zero_approx(NpcAvatar.yaw_towards(Vector3(0, 0, 1))),
"+Z is the neutral facing")
var x_dir := NpcAvatar.yaw_towards(Vector3(1, 0, 0))
var b := TestUtils.check(is_equal_approx(x_dir, PI / 2.0),
"+X turns a quarter turn")
var back := NpcAvatar.yaw_towards(Vector3(0, 0, -1))
var c := TestUtils.check(absf(absf(back) - PI) < 0.001, "-Z is a half turn")
var direction := Vector3(0.4, 0, -0.9).normalized()
var yaw := NpcAvatar.yaw_towards(direction)
var facing := Basis(Vector3.UP, yaw) * Vector3(0, 0, 1)
var d := TestUtils.check(facing.dot(direction) > 0.999,
"the avatar's +Z face points along the walk direction")
var e := TestUtils.check(is_zero_approx(NpcAvatar.yaw_towards(Vector3.ZERO)),
"zero direction falls back to neutral")
return a and b and c and d and e
func test_queue_slots_are_spaced_and_unique() -> bool:
var seen := {}
var ok := true