Pawn mover: walk waypoints at a configurable speed #93

Merged
liamjd merged 2 commits from 80-pawn-mover-walks-waypoints into main 2026-09-13 14:23:53 +01:00
Owner

Closes #80

Turns a find_path() result into a capsule crossing the map. The record holds state; the view holds the walk.

What is here

  • PawnView — follow(), stop(), remaining_path(), and a _process() that spends a per-frame distance budget across as many waypoints as it reaches, so a fast pawn is not capped at one tile per frame. Faces its direction of travel on Y only. Emits cell_entered per waypoint and arrived once at the end.
  • Pawns.move_to() / Pawns.stop() — a facade so the record and the view cannot drift: state flips and the walk starts in one place, rather than every caller remembering to do both around a bare view.follow(). cell and state follow the view's signals.
  • tools/probe_pawn_terrace.gd — the headed visual check, see below.

Two units of measure carry most of the risk and both are covered: speed is in tiles per second while positions are world units (tile_size is 2.0, not 1.0), and Y is resampled from the terrain every frame rather than interpolated between waypoints — an interpolated Y passes straight through a riser.

Verification

Unit tests: 332 tests, 331 passing, 1 pre-existing pending (test_task_tree, tracked as #85). 18 new tests in test_pawn_view.gd plus additions to test_pawns.gd. Each drives the mover with explicit _process(delta) calls rather than awaiting frames, so "known delta → known distance" is exact arithmetic.

Headed visual check: done, via a probe rather than in the game scene. There is no in-game way to command a pawn yet — #78 owns selection and #81 owns the right-click move — so the AC could not be met the obvious way. tools/probe_pawn_terrace.gd builds a staircase (up three terraces, down two), walks a pawn across it, and drops a marker every 0.2s. A single frame cannot tell riding a terrace apart from cutting through it; a trail can.

Findings:

  • The trail is a staircase, not a ramp. Every sampled Y is exactly surface_y(level) + pawn_height / 2 — 1.50 → 2.30 → 2.80 → 3.30 → 3.80 and back down, never an intermediate value.
  • A* does route over a one-level riser — find_path returned 13 waypoints for the 12-tile crossing, so max_step_levels = 1 permits the climb rather than treating each terrace as a cliff. Worth proving rather than assuming.
  • The descent rides the surface as cleanly as the climb — that is the case where an interpolated Y sinks into the ground rather than floating over it.
  • arrived fired exactly once, and the probe loop exited on it rather than on its step cap.

Images land in tmp/ (gitignored): probe_pawn_terrace_trail.png side-on, probe_pawn_terrace_angled.png from the game's own camera angle as a sanity check that the pawn is where the side-on view claims.

Left undone

The vertical step is instantaneous — the pawn's Y jumps a full level_height in the single frame its XZ crosses the tile boundary, with no intermediate sample on any riser. That is what _move_to_ground() is written to do and what the unit test asserts, and it is the correct model: the pawn is on one terrace or the other, never halfway up a cliff face. But in motion it will read as a teleport, and at the boundary frame the capsule's lower half briefly clips the riser corner.

Filed as #92 rather than fixed here, because the obvious fix breaks test_height_steps_with_the_terrain_instead_of_ramping_between_waypoints on purpose — smoothing belongs in a presentation layer that lags the authoritative Y, and that wants its own decision about hop-vs-ease and whether it generalises to vehicles later.

The probe is kept rather than deleted, matching tools/screenshot_buildings.gd and friends.

🤖 Generated with Claude Code

https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ

Closes #80 Turns a `find_path()` result into a capsule crossing the map. The record holds state; the view holds the walk. ## What is here - **`PawnView`** — `follow()`, `stop()`, `remaining_path()`, and a `_process()` that spends a per-frame distance budget across as many waypoints as it reaches, so a fast pawn is not capped at one tile per frame. Faces its direction of travel on Y only. Emits `cell_entered` per waypoint and `arrived` once at the end. - **`Pawns.move_to()` / `Pawns.stop()`** — a facade so the record and the view cannot drift: `state` flips and the walk starts in one place, rather than every caller remembering to do both around a bare `view.follow()`. `cell` and `state` follow the view's signals. - **`tools/probe_pawn_terrace.gd`** — the headed visual check, see below. Two units of measure carry most of the risk and both are covered: `speed` is in **tiles** per second while positions are world units (`tile_size` is 2.0, not 1.0), and Y is **resampled from the terrain every frame** rather than interpolated between waypoints — an interpolated Y passes straight through a riser. ## Verification **Unit tests: 332 tests, 331 passing, 1 pre-existing pending** (`test_task_tree`, tracked as #85). 18 new tests in `test_pawn_view.gd` plus additions to `test_pawns.gd`. Each drives the mover with explicit `_process(delta)` calls rather than awaiting frames, so "known delta → known distance" is exact arithmetic. **Headed visual check: done, via a probe rather than in the game scene.** There is no in-game way to command a pawn yet — #78 owns selection and #81 owns the right-click move — so the AC could not be met the obvious way. `tools/probe_pawn_terrace.gd` builds a staircase (up three terraces, down two), walks a pawn across it, and drops a marker every 0.2s. A single frame cannot tell riding a terrace apart from cutting through it; a trail can. Findings: - The trail is a **staircase, not a ramp**. Every sampled Y is exactly `surface_y(level) + pawn_height / 2` — 1.50 → 2.30 → 2.80 → 3.30 → 3.80 and back down, never an intermediate value. - **A\* does route over a one-level riser** — `find_path` returned 13 waypoints for the 12-tile crossing, so `max_step_levels = 1` permits the climb rather than treating each terrace as a cliff. Worth proving rather than assuming. - **The descent rides the surface as cleanly as the climb** — that is the case where an interpolated Y sinks into the ground rather than floating over it. - `arrived` fired exactly once, and the probe loop exited on it rather than on its step cap. Images land in `tmp/` (gitignored): `probe_pawn_terrace_trail.png` side-on, `probe_pawn_terrace_angled.png` from the game's own camera angle as a sanity check that the pawn is where the side-on view claims. ## Left undone **The vertical step is instantaneous** — the pawn's Y jumps a full `level_height` in the single frame its XZ crosses the tile boundary, with no intermediate sample on any riser. That is what `_move_to_ground()` is written to do and what the unit test asserts, and it is the correct *model*: the pawn is on one terrace or the other, never halfway up a cliff face. But in motion it will read as a teleport, and at the boundary frame the capsule's lower half briefly clips the riser corner. Filed as **#92** rather than fixed here, because the obvious fix breaks `test_height_steps_with_the_terrain_instead_of_ramping_between_waypoints` on purpose — smoothing belongs in a presentation layer that lags the authoritative Y, and that wants its own decision about hop-vs-ease and whether it generalises to vehicles later. The probe is kept rather than deleted, matching `tools/screenshot_buildings.gd` and friends. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ
There is no in-game way to command a pawn yet (#78 and #81 own that), so
#80's visual AC could not be checked in the real game scene. This builds
the smallest world that answers the question and drives PawnView directly,
the way test_pawn_view.gd does.

A trail rather than a screenshot of the walk: a single frame cannot tell
riding a terrace apart from cutting through it. Sampling the pawn as it
goes and leaving a marker at each sample turns the whole trajectory into
one still - a staircase of dots is correct, a diagonal ramp is the bug.

Confirms A* routes over a one-level riser, and that Y is exactly
surface_y + half a body at every sample, on the descent as well as the
climb. Also shows the step is instantaneous, filed as #92.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ
liamjd merged commit db1f993333 into main 2026-09-13 14:23:53 +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!93
No description provided.