Flash a pawn red when its move command fails #102

Merged
liamjd merged 2 commits from 100-flash-pawn-on-failed-move into main 2026-09-15 09:47:25 +01:00
Owner

Closes #100, and finishes the half of #83's design that was left undone.

What changed

  • PawnView.flash(colour, duration) — tints the capsule and fades it back over FLASH_DURATION (0.4s), then clears itself and emits flash_finished. is_flashing() reports the state. A second flash while one is running restarts it rather than stacking a second tween on the same material.
  • Pawns.move_to() flashes the view when find_path() comes back empty.
  • tools/screenshot_pawn_flash.gd — the headed probe.

Two decisions worth reviewing

The flash lives in move_to(), not in repath_pawns(). The ticket asked for a call at each failing caller. One rule in move_to()'s empty-path branch covers the sealed re-path, a direct unreachable command, and whatever #78's picker ends up calling, without any of them having to remember. The earlier return falses are deliberately left silent: they are programming errors or a registry with no views, where there is no view to flash.

The tint is a material_override. pawn.tscn's CapsuleMesh and its StandardMaterial3D are sub-resources shared by every instance of the scene, so tinting through mesh.material would flash every pawn on the map. test_a_flash_leaves_the_shared_mesh_material_alone pins that, and the probe checks it on screen.

The Tween also makes the flash wall-clock without opting out of anything, which is what the ticket wanted: a pause freezes the walk, not the feedback. test_a_flash_runs_while_the_game_clock_is_paused covers it.

Verification

Full GUT suite: 395 passing, 1 pre-existing pending (test_what_a_cancelled_child_should_do_to_its_parent_is_undecided, tracked as #85). No orphans.

Eight new tests — six in test_pawn_view.gd for the flash itself, two in test_pawn_repath.gd for the trigger:

  • a flash tints via the override and starts at the flash colour
  • a flash leaves the shared mesh material alone, and no bystander pawn is tinted
  • a flash clears itself and emits flash_finished once
  • a second flash restarts rather than stacking
  • a zero-duration flash is a no-op
  • a flash runs while GameClock is paused
  • a building that seals the only route flashes the pawn
  • a successful re-path does not flash it

Headed probe, all checks passed on the real game.tscn:

[probe] PASS  CHECK 0: PRECONDITION: an untroubled pawn is not red (0.47)
[probe] PASS  CHECK 1: a move with nowhere to go is refused
[probe] PASS  CHECK 1: and the pawn is left flashing
[probe] PASS  CHECK 1: the pawn renders red while flashing (0.47 -> 0.70)
[probe] PASS  CHECK 2: no other pawn is flashing
[probe] PASS  CHECK 3: the flash ends on its own
[probe] PASS  CHECK 3: and the pawn renders its normal colour again (0.47)
[probe] PASS  CHECK 4: a second flash restarts the first rather than sticking

Caveat on the design, not the code

Red on an already-red pawn is weak feedback. The pawn's normal albedo is a terracotta (0.70, 0.33, 0.25); the flash is (0.90, 0.15, 0.15). Side by side in tmp/pawn_flash_2_flashing.png and tmp/pawn_flash_3_after.png the difference is clearly there — brighter and more saturated — but it reads as "the pawn got a bit more vivid", not as an alarm, and at map zoom it would be easy to miss entirely. It is measurable (red-minus-green 0.47 → 0.70), which is why the probe can assert it, but measurable is not the same as noticeable.

Worth considering as a follow-up: a brighter non-red colour, a repeated pulse rather than a single fade, or pairing it with the #57 toast so the flash is corroboration rather than the whole message. Left as specified for now.

🤖 Generated with Claude Code

`Closes #100`, and finishes the half of #83's design that was left undone. ## What changed - **`PawnView.flash(colour, duration)`** — tints the capsule and fades it back over `FLASH_DURATION` (0.4s), then clears itself and emits `flash_finished`. `is_flashing()` reports the state. A second flash while one is running restarts it rather than stacking a second tween on the same material. - **`Pawns.move_to()`** flashes the view when `find_path()` comes back empty. - **`tools/screenshot_pawn_flash.gd`** — the headed probe. ## Two decisions worth reviewing **The flash lives in `move_to()`, not in `repath_pawns()`.** The ticket asked for a call at each failing caller. One rule in `move_to()`'s empty-path branch covers the sealed re-path, a direct unreachable command, and whatever #78's picker ends up calling, without any of them having to remember. The earlier `return false`s are deliberately left silent: they are programming errors or a registry with no views, where there is no view to flash. **The tint is a `material_override`.** `pawn.tscn`'s `CapsuleMesh` and its `StandardMaterial3D` are sub-resources shared by every instance of the scene, so tinting through `mesh.material` would flash every pawn on the map. `test_a_flash_leaves_the_shared_mesh_material_alone` pins that, and the probe checks it on screen. The `Tween` also makes the flash wall-clock without opting out of anything, which is what the ticket wanted: a pause freezes the walk, not the feedback. `test_a_flash_runs_while_the_game_clock_is_paused` covers it. ## Verification Full GUT suite: **395 passing, 1 pre-existing pending** (`test_what_a_cancelled_child_should_do_to_its_parent_is_undecided`, tracked as #85). No orphans. Eight new tests — six in `test_pawn_view.gd` for the flash itself, two in `test_pawn_repath.gd` for the trigger: - a flash tints via the override and starts at the flash colour - a flash leaves the shared mesh material alone, and no bystander pawn is tinted - a flash clears itself and emits `flash_finished` once - a second flash restarts rather than stacking - a zero-duration flash is a no-op - a flash runs while `GameClock` is paused - a building that seals the only route flashes the pawn - a successful re-path does *not* flash it Headed probe, all checks passed on the real `game.tscn`: ``` [probe] PASS CHECK 0: PRECONDITION: an untroubled pawn is not red (0.47) [probe] PASS CHECK 1: a move with nowhere to go is refused [probe] PASS CHECK 1: and the pawn is left flashing [probe] PASS CHECK 1: the pawn renders red while flashing (0.47 -> 0.70) [probe] PASS CHECK 2: no other pawn is flashing [probe] PASS CHECK 3: the flash ends on its own [probe] PASS CHECK 3: and the pawn renders its normal colour again (0.47) [probe] PASS CHECK 4: a second flash restarts the first rather than sticking ``` ## Caveat on the design, not the code **Red on an already-red pawn is weak feedback.** The pawn's normal albedo is a terracotta `(0.70, 0.33, 0.25)`; the flash is `(0.90, 0.15, 0.15)`. Side by side in `tmp/pawn_flash_2_flashing.png` and `tmp/pawn_flash_3_after.png` the difference is clearly there — brighter and more saturated — but it reads as "the pawn got a bit more vivid", not as an alarm, and at map zoom it would be easy to miss entirely. It is measurable (red-minus-green 0.47 → 0.70), which is why the probe can assert it, but measurable is not the same as noticeable. Worth considering as a follow-up: a brighter non-red colour, a repeated pulse rather than a single fade, or pairing it with the #57 toast so the flash is corroboration rather than the whole message. Left as specified for now. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A pawn that cannot go where it was sent stands still, which looks exactly
like a pawn that has arrived. PawnView.flash() tints the capsule and fades
it back, and Pawns.move_to() calls it on the one failure the player can
see: find_path() came back empty.

Flashing inside move_to() rather than at each caller means the re-path
sealed by a building (#83) and a direct command at an unreachable tile are
covered by one rule. The earlier returns are programming errors or a
registry with no views, where there is nothing to flash.

The tint is a material_override, not a write to the mesh's material:
pawn.tscn's CapsuleMesh and StandardMaterial3D are sub-resources shared by
every instance of the scene, so the obvious way would flash the whole
colony. A Tween carries it, which also makes it wall-clock rather than
GameClock time - feedback about a command should not stretch with game
speed or stall while paused.

tools/screenshot_pawn_flash.gd is the headed half of the AC: headless
draws nothing under gl_compatibility, so it walls a pawn in, refuses a
move and samples the pixels before, during and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
liamjd merged commit 4c569d5da1 into main 2026-09-15 09:47:25 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!102
No description provided.