diff --git a/DONE.md b/DONE.md index beb9136..11a8578 100644 --- a/DONE.md +++ b/DONE.md @@ -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. diff --git a/MEMORY.md b/MEMORY.md index 53ca3c7..a278a21 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 diff --git a/TODO.md b/TODO.md index c12579e..4f743c1 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index 8cf5bf7..383fde3 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 236020f..6ee593a 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -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: diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 0133c8c..1e92705 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -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