Phase 2 — Pawn, pathfinder, click-to-select / click-to-move

Pawn (scenes/pawn/{tscn,gd}, ~108 lines, gdscript-refactor agent):
- Node2D root (no physics — grid-snapped lerped motion); name + state labels
- _draw() paints body disc with hue derived from name.hash(), dark outline,
  yellow selection ring when selected
- Clock = EventBus.sim_tick: each tick advances _step_progress by 1/10;
  at 1.0 snaps tile to next waypoint, pops path. STEP_TICKS = 10 →
  1 tile / 0.5 s at 1×, scales with Sim speed for free (pause/Fast/Ultra)
- _process() lerps render position between current and next tile every
  render frame for smooth visual between sim ticks
- Public API: setup, walk_along_path, is_walking, set_selected,
  signals walk_started/walk_completed/arrived_at_destination

Pathfinder (scenes/world/pathfinder.gd, ~110 lines, gdscript-refactor agent):
- AStarGrid2D wrapper, 80² region, DIAGONAL_MODE_NEVER (Rimworld
  4-directional), Manhattan heuristic
- API: setup, set_cell_walkable (emits walkability_changed signal),
  is_walkable, find_path (excludes start tile, includes end), benchmark
- find_path returns empty Array[Vector2i] for OOB endpoints, solid
  destination, or disconnected areas

Selection (scenes/world/selection.gd, ~85 lines, Opus):
- Lives as a Node child of World; _unhandled_input handles mouse clicks
- Click-vs-drag discrimination: 8 px max drift + 300 ms max duration →
  drags belong to the camera, only true clicks select/command
- Click on pawn → select (yellow ring); click on walkable empty tile
  with a pawn selected → pathfinder.find_path + pawn.walk_along_path

World autoload (autoload/world.gd):
- Added pawn registry: register_pawn, unregister_pawn, pawn_at_tile, clear_pawns
- Untyped Array (Array[Pawn] hits Godot's class_name-not-yet-registered
  timing in autoload init; duck typing fine for current consumers)

World scene (scenes/world/{tscn,gd}):
- Pathfinder + Selection nodes added as children
- _ready() wires: pathfinder.setup(MAP_SIZE_TILES), walls → pathfinder
  (28 cells from 8×8 stone ring marked impassable), selection.bind(pathfinder),
  spawns 3 pawns (Bram/Cora/Edda) at (20/25/30, 40), runs spike benchmark
- main.gd bootstrap line bumped Phase 1 → Phase 2

i18n: 2 new keys (pawn.state.idle, pawn.state.walking)

Spike result — AStarGrid2D path-query timing at 80²:
- 36 paths (all 4-corner pairs × 3 iterations)
- min 6 μs, avg 9.1 μs, max 18 μs
- ~55× faster than the 'sub-millisecond' target in architecture.md

MCP runtime verification:
- play_scene → 3 pawns visible with distinct hashed-hue body colours
- execute_game_script: pathfinder.find_path((20,40)→(50,40)) returns
  38-step path (30 straight + 8 detour around the ring)
- bram.walk_along_path(path) → screenshot caught him mid-walk on south
  side of ring with state='walking' + selection ring visible
- arrival snapshot: state='idle'

Phase 2 gotcha (documented in implementation.md): class_name registration
happens at editor scan-time, not headless-load-time. First headless run
after authoring class_name files fails until reload_project rebuilds the
global class cache. Workflow: agent writes → MCP reload_project → headless
validate. Documented for future phases.

Delegation report this phase:
- gdscript-refactor (Sonnet) #1: Pawn class — scene, script, draw logic,
  movement loop, i18n keys. ~108 lines pawn.gd + 22 lines pawn.tscn.
  Headless-validated by the subagent (note: validated before world.gd's
  Pawn reference was added).
- gdscript-refactor (Sonnet) #2: Pathfinder class — AStarGrid2D wrapper,
  4-dir Manhattan, benchmark utility. ~110 lines pathfinder.gd. Headless-
  validated by the subagent.
- Opus: Selection module + World autoload registry + scene integration
  (world.tscn/gd) + MCP-driven runtime verification + spike benchmark
  + class_name workflow gotcha documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-10 20:47:08 +01:00
parent 836dfdd716
commit cd265b87c0
13 changed files with 475 additions and 24 deletions

109
scenes/world/pathfinder.gd Normal file
View file

@ -0,0 +1,109 @@
class_name Pathfinder
extends Node
## AStarGrid2D wrapper for rimlike's 4-directional tile pathfinding.
##
## One grid covers the full map. Walkability is updated in O(1) per cell
## change (wall placed, door toggled, furniture added/removed). All path
## queries are sub-millisecond at 80² and within the 120² ceiling.
##
## Usage:
## pathfinder.setup(World.MAP_SIZE_TILES)
## pathfinder.set_cell_walkable(cell, false) # e.g. after wall placed
## var path := pathfinder.find_path(from_cell, to_cell)
##
## `find_path` returns tile coords EXCLUDING `from`, INCLUDING `to`.
## Returns an empty Array[Vector2i] when the destination is unreachable.
const TILE_SIZE_PX: int = 16
signal walkability_changed(cell: Vector2i)
var _astar: AStarGrid2D
var _map_size_tiles: Vector2i
## Configure the grid. Must be called once before any other method.
## Typically called by World._ready() after the map size is known.
func setup(map_size_tiles: Vector2i) -> void:
_map_size_tiles = map_size_tiles
_astar = AStarGrid2D.new()
_astar.region = Rect2i(0, 0, map_size_tiles.x, map_size_tiles.y)
_astar.cell_size = Vector2(TILE_SIZE_PX, TILE_SIZE_PX)
_astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER # 4-directional, Rimworld-like
_astar.default_compute_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
_astar.default_estimate_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
_astar.update()
Audit.log("pathfinder", "AStarGrid2D online for %s tiles (4-dir, Manhattan)" % map_size_tiles)
## Mark a tile as passable or impassable and emit walkability_changed.
## Called whenever a wall, door, or furniture state changes.
## Does not log — Phase 5 triggers many of these per tick.
func set_cell_walkable(cell: Vector2i, walkable: bool) -> void:
_astar.set_point_solid(cell, not walkable)
emit_signal("walkability_changed", cell)
## Returns true if `cell` is inside the configured region AND is not solid.
func is_walkable(cell: Vector2i) -> bool:
return _astar.is_in_boundsv(cell) and not _astar.is_point_solid(cell)
## Returns the path from `from` to `to` as tile-coordinate steps.
## The returned array EXCLUDES `from` and INCLUDES `to`.
## Returns an empty Array[Vector2i] when:
## - either endpoint is outside the configured region
## - `to` is solid (impassable)
## - no path exists (area is disconnected)
func find_path(from: Vector2i, to: Vector2i) -> Array[Vector2i]:
if not _astar.is_in_boundsv(from) or not _astar.is_in_boundsv(to):
return [] as Array[Vector2i]
if _astar.is_point_solid(to):
return [] as Array[Vector2i]
var raw_path: Array[Vector2i] = _astar.get_id_path(from, to)
if raw_path.is_empty():
# Both endpoints are in-bounds and destination is walkable; the
# area must be disconnected. Log for debugging, not a caller-bug.
Audit.log("pathfinder", "no path: %s%s" % [from, to])
return [] as Array[Vector2i]
# get_id_path includes the start tile at index 0; drop it per API contract.
raw_path.remove_at(0)
return raw_path
## Spike / debug utility. Times `find_path` over `pairs` repeated `iterations`
## times and returns timing statistics. Each entry in `pairs` is [Vector2i, Vector2i].
## Uses Time.get_ticks_usec() for microsecond resolution.
func benchmark(pairs: Array, iterations: int = 1) -> Dictionary:
var min_us: int = 9223372036854775807 # INT64_MAX
var max_us: int = 0
var total_us: int = 0
var total_paths: int = 0
for _i in iterations:
for pair in pairs:
var t_start: int = Time.get_ticks_usec()
find_path(pair[0], pair[1])
var elapsed: int = Time.get_ticks_usec() - t_start
if elapsed < min_us:
min_us = elapsed
if elapsed > max_us:
max_us = elapsed
total_us += elapsed
total_paths += 1
var avg_us: float = float(total_us) / float(total_paths) if total_paths > 0 else 0.0
Audit.log("pathfinder", "bench: %d paths, avg=%.1f us, max=%d us" % [total_paths, avg_us, max_us])
return {
"min_us": min_us,
"max_us": max_us,
"avg_us": avg_us,
"total_paths": total_paths,
}