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

View file

@ -1,10 +1,11 @@
extends Node2D
## Phase 1 world view. 80×80 TileMap with 6 layers, placeholder tiles.
## Phase 2 world view. 80×80 TileMap with 6 layers, 3 sample pawns, pathfinder,
## click-to-select / click-to-move selection.
##
## Real ElvGames art lands in Phase 5+ (wood walls custom-authored on
## FG_Houses, stone walls autotiled from FG_Fortress per the 2026-05-10
## audit lock). Phase 1 is just "render an 80² map" and exists to prove
## the layer pipeline + camera UX + speed loop end-to-end.
## audit lock). The procedural placeholder tileset is enough to prove the
## TileMap pipeline + pawn movement + camera + pathfinding end-to-end.
##
## TileMap layer indices follow docs/architecture.md:
## 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof · 5 Fog
@ -21,16 +22,27 @@ const TILE_STONE_DARK: Vector2i = Vector2i(3, 0)
const PLACEHOLDER_SOURCE_ID: int = 0
const PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn")
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
const SAMPLE_PAWNS: Array[Dictionary] = [
{"name": "Bram", "tile": Vector2i(20, 40)},
{"name": "Cora", "tile": Vector2i(25, 40)},
{"name": "Edda", "tile": Vector2i(30, 40)},
]
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var floor_layer: TileMapLayer = $Floor
@onready var wall_layer: TileMapLayer = $Wall
@onready var designation_layer: TileMapLayer = $Designation
@onready var roof_layer: TileMapLayer = $Roof
@onready var fog_layer: TileMapLayer = $Fog
@onready var pathfinder: Pathfinder = $Pathfinder
@onready var selection: Selection = $Selection
func _ready() -> void:
Audit.log("world", "Phase 1 — building %d×%d world view." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
Audit.log("world", "Phase 2 — building %d×%d world + pawns." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
var tileset := _build_placeholder_tileset()
for layer in [terrain_layer, floor_layer, wall_layer, designation_layer, roof_layer, fog_layer]:
layer.tile_set = tileset
@ -38,11 +50,20 @@ func _ready() -> void:
_paint_sample_walls()
_apply_camera_bounds()
pathfinder.setup(MAP_SIZE_TILES)
_wire_walls_to_pathfinder()
selection.bind(pathfinder)
_spawn_sample_pawns()
_run_pathfinder_spike()
func world_bounds_px() -> Rect2:
return Rect2(Vector2.ZERO, Vector2(MAP_SIZE_TILES * TILE_SIZE_PX))
# ── tileset & map painting ──────────────────────────────────────────────────
func _build_placeholder_tileset() -> TileSet:
# Four 16×16 placeholder tiles laid out as a 4×1 atlas. No PNG dependency
# — atlas built at runtime from a programmatic Image. Real ElvGames art
@ -80,18 +101,14 @@ func _build_placeholder_tileset() -> TileSet:
func _paint_terrain() -> void:
# Solid grass for now. Phase 4+ introduces ore veins, trees-as-entities,
# water, etc. — this fill is a baseline.
for x in MAP_SIZE_TILES.x:
for y in MAP_SIZE_TILES.y:
terrain_layer.set_cell(Vector2i(x, y), PLACEHOLDER_SOURCE_ID, TILE_GRASS)
func _paint_sample_walls() -> void:
# An 8×8 stone ring near the map centre as a visual landmark. Proves the
# wall layer renders on top of terrain and gives the camera something to
# pan toward in the demo. Phase 5 deletes this and stands up real player-
# built walls.
# An 8×8 stone ring near the map centre as a visual landmark + pathfinding
# obstacle so the demo proves pawns route around walls.
var origin := Vector2i(36, 36)
var size: int = 8
for i in size:
@ -101,6 +118,49 @@ func _paint_sample_walls() -> void:
wall_layer.set_cell(origin + Vector2i(size - 1, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
# ── pathfinder + pawns ──────────────────────────────────────────────────────
func _wire_walls_to_pathfinder() -> void:
# Wall cells block pathing. Re-runs on Phase 5 build/destroy events later.
var wall_cells := wall_layer.get_used_cells()
for cell in wall_cells:
pathfinder.set_cell_walkable(cell, false)
Audit.log("world", "%d wall cells marked impassable" % wall_cells.size())
func _spawn_sample_pawns() -> void:
for spawn_data in SAMPLE_PAWNS:
var p: Pawn = PAWN_SCENE.instantiate()
add_child(p)
p.setup(spawn_data["name"], spawn_data["tile"])
World.register_pawn(p)
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
func _run_pathfinder_spike() -> void:
# Phase 2 acceptance spike (~30 min): "AStarGrid2D path-query timing at 80²
# with 6 pawns simultaneously requesting paths. Confirm sub-millisecond."
# We benchmark all 4-corner pairs × 3 iterations = 36 path queries.
var corners := [
Vector2i(2, 2),
Vector2i(MAP_SIZE_TILES.x - 3, 2),
Vector2i(2, MAP_SIZE_TILES.y - 3),
Vector2i(MAP_SIZE_TILES.x - 3, MAP_SIZE_TILES.y - 3),
]
var pairs: Array = []
for a in corners:
for b in corners:
if a != b:
pairs.append([a, b])
var result: Dictionary = pathfinder.benchmark(pairs, 3)
Audit.log("world", "spike: %d paths min=%d us avg=%.1f us max=%d us" % [
result["total_paths"], result["min_us"], result["avg_us"], result["max_us"]
])
# ── camera bounds ───────────────────────────────────────────────────────────
func _apply_camera_bounds() -> void:
var cam := get_node_or_null("CameraRig")
if cam == null: