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>
93 lines
2.8 KiB
GDScript
93 lines
2.8 KiB
GDScript
extends Node
|
|
class_name Selection
|
|
## Pawn selection + click-to-move input handler.
|
|
##
|
|
## A click on a pawn selects it; a click on a walkable tile while a pawn is
|
|
## selected pathfinds + commands the walk. Drags belong to the camera (pan)
|
|
## — we discriminate clicks from drags by motion + duration thresholds.
|
|
##
|
|
## Lives as a child of World; `_unhandled_input` runs after the camera rig
|
|
## and after any CanvasLayer UI swallows its own clicks.
|
|
|
|
const CLICK_MAX_DRIFT_PX: float = 8.0
|
|
const CLICK_MAX_DURATION_MS: int = 300
|
|
|
|
var _pathfinder: Pathfinder = null
|
|
var _selected_pawn: Pawn = null
|
|
|
|
var _press_screen_pos: Vector2 = Vector2.ZERO
|
|
var _press_time_ms: int = 0
|
|
var _pressing: bool = false
|
|
|
|
|
|
func bind(pathfinder: Pathfinder) -> void:
|
|
assert(pathfinder != null, "Selection.bind: pathfinder is null")
|
|
_pathfinder = pathfinder
|
|
|
|
|
|
func selected() -> Pawn:
|
|
return _selected_pawn
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if not (event is InputEventMouseButton):
|
|
return
|
|
if event.button_index != MOUSE_BUTTON_LEFT:
|
|
return
|
|
if event.pressed:
|
|
_press_screen_pos = event.position
|
|
_press_time_ms = Time.get_ticks_msec()
|
|
_pressing = true
|
|
return
|
|
if not _pressing:
|
|
return
|
|
_pressing = false
|
|
var drift: float = event.position.distance_to(_press_screen_pos)
|
|
var dt_ms: int = Time.get_ticks_msec() - _press_time_ms
|
|
# Anything that drifted more than a few pixels or sat for more than 300 ms
|
|
# is the camera's drag-pan; ignore it as a select/move action.
|
|
if drift > CLICK_MAX_DRIFT_PX or dt_ms > CLICK_MAX_DURATION_MS:
|
|
return
|
|
_handle_click(event.position)
|
|
|
|
|
|
func _handle_click(screen_pos: Vector2) -> void:
|
|
if _pathfinder == null:
|
|
Audit.log("selection", "click before bind() — ignored")
|
|
return
|
|
|
|
var world_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * screen_pos
|
|
var tile: Vector2i = Vector2i(
|
|
floori(world_pos.x / float(Pawn.TILE_SIZE_PX)),
|
|
floori(world_pos.y / float(Pawn.TILE_SIZE_PX)),
|
|
)
|
|
|
|
# Click on a pawn → select.
|
|
var hit_pawn: Pawn = World.pawn_at_tile(tile)
|
|
if hit_pawn != null:
|
|
_select(hit_pawn)
|
|
return
|
|
|
|
# Empty tile with no current selection → no-op.
|
|
if _selected_pawn == null:
|
|
return
|
|
|
|
# Empty walkable tile with a selection → pathfind + command move.
|
|
if not _pathfinder.is_walkable(tile):
|
|
Audit.log("selection", "destination %s not walkable" % tile)
|
|
return
|
|
var path: Array[Vector2i] = _pathfinder.find_path(_selected_pawn.tile, tile)
|
|
if path.is_empty():
|
|
Audit.log("selection", "no path %s → %s" % [_selected_pawn.tile, tile])
|
|
return
|
|
_selected_pawn.walk_along_path(path)
|
|
|
|
|
|
func _select(pawn: Pawn) -> void:
|
|
if _selected_pawn == pawn:
|
|
return
|
|
if _selected_pawn != null:
|
|
_selected_pawn.set_selected(false)
|
|
_selected_pawn = pawn
|
|
pawn.set_selected(true)
|
|
Audit.log("selection", "selected %s at %s" % [pawn.pawn_name, pawn.tile])
|