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

@ -7,7 +7,7 @@ extends Node2D
func _ready() -> void:
Audit.log("main", "Phase 1 — world view + speed UI online.")
Audit.log("main", "Phase 2 — world + pawns + pathfinder + selection online.")
# Autoloads — keep these asserts; cheap and catch a renamed-autoload
# regression instantly.
assert(World != null, "World autoload missing")

128
scenes/pawn/pawn.gd Normal file
View file

@ -0,0 +1,128 @@
extends Node2D
## Pawn entity — grid-snapped, sim-tick-driven movement with smooth render lerp.
##
## Movement model (docs/architecture.md "Pawn movement"):
## At 1× speed, crossing one tile costs STEP_TICKS sim ticks (10 ticks = 0.5 s
## at 20 Hz). Each sim tick advances _step_progress by 1/STEP_TICKS. When
## progress reaches 1.0 the pawn snaps to the next waypoint.
##
## Speed scaling is free: Pause → no ticks → pawn frozen; Ultra → 12× ticks/s →
## pawn crosses the map in ~7 s real time. No per-pawn speed handling needed.
##
## Render: _process() lerps world-position between current and next tile every
## render frame at 60 Hz — motion is smooth even at low sim Hz.
class_name Pawn
const STEP_TICKS: int = 10
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
signal walk_started
signal walk_completed
signal arrived_at_destination(tile: Vector2i)
@export var pawn_name: String = ""
var tile: Vector2i = Vector2i.ZERO
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@onready var _name_label: Label = $NameLabel
@onready var _state_label: Label = $StateLabel
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
_state_label.text = Strings.t(&"pawn.state.idle")
func setup(p_name: String, start_tile: Vector2i) -> void:
pawn_name = p_name
tile = start_tile
position = _tile_to_world(tile)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.idle")
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
# ── public API ──────────────────────────────────────────────────────────────
func walk_along_path(new_path: Array[Vector2i]) -> void:
if new_path.is_empty():
return
var was_walking := is_walking()
_path = new_path.duplicate()
# _step_progress carries over; when it hits 1.0 the pawn snaps to
# the first tile of the new path and picks up the new direction.
if not was_walking:
walk_started.emit()
_state_label.text = Strings.t(&"pawn.state.walking")
Audit.log("pawn", "%s walk path len %d%s" % [pawn_name, new_path.size(), new_path[-1]])
func is_walking() -> bool:
return not _path.is_empty()
func set_selected(value: bool) -> void:
if _selected == value:
return
_selected = value
queue_redraw()
func is_selected() -> bool:
return _selected
# ── sim tick ────────────────────────────────────────────────────────────────
func _on_sim_tick(_tick_number: int) -> void:
if not is_walking():
return
_step_progress += 1.0 / float(STEP_TICKS)
if _step_progress >= 1.0:
tile = _path[0]
_path.remove_at(0)
_step_progress = 0.0
if _path.is_empty():
_state_label.text = Strings.t(&"pawn.state.idle")
walk_completed.emit()
arrived_at_destination.emit(tile)
Audit.log("pawn", "%s arrived at %s" % [pawn_name, tile])
# ── render ──────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
var from_world := _tile_to_world(tile)
var next := _path[0] if is_walking() else tile
var to_world := _tile_to_world(next)
position = from_world.lerp(to_world, _step_progress)
func _draw() -> void:
# Body disc — colour derived deterministically from pawn name so each pawn
# is visually distinct without any art dependency.
var hue := float(pawn_name.hash() % 360) / 360.0
var body_colour := Color.from_hsv(hue, 0.7, 0.85)
draw_circle(Vector2.ZERO, 6.0, body_colour)
# Dark outline ring.
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0)
# Selection ring.
if _selected:
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)
# ── helpers ─────────────────────────────────────────────────────────────────
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)

1
scenes/pawn/pawn.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cihxqlnvbn52y

23
scenes/pawn/pawn.tscn Normal file
View file

@ -0,0 +1,23 @@
[gd_scene load_steps=2 format=3 uid="uid://pawn_scene"]
[ext_resource type="Script" path="res://scenes/pawn/pawn.gd" id="1_pawn"]
[node name="Pawn" type="Node2D"]
script = ExtResource("1_pawn")
[node name="NameLabel" type="Label" parent="."]
position = Vector2(-20, -18)
size = Vector2(40, 12)
theme_override_font_sizes/font_size = 8
horizontal_alignment = 1
vertical_alignment = 1
text = ""
[node name="StateLabel" type="Label" parent="."]
position = Vector2(-20, 10)
size = Vector2(40, 10)
theme_override_font_sizes/font_size = 7
horizontal_alignment = 1
vertical_alignment = 1
text = ""
modulate = Color(0.8, 0.8, 0.8, 1)

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,
}

View file

@ -0,0 +1 @@
uid://b0amlu3juxggi

93
scenes/world/selection.gd Normal file
View file

@ -0,0 +1,93 @@
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])

View file

@ -0,0 +1 @@
uid://c643o8ycfvir2

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:

View file

@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://rimlike_world"]
[gd_scene load_steps=5 format=3 uid="uid://rimlike_world"]
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
[ext_resource type="Script" path="res://scenes/world/pathfinder.gd" id="3_pathfinder"]
[ext_resource type="Script" path="res://scenes/world/selection.gd" id="4_selection"]
[node name="World" type="Node2D"]
script = ExtResource("1_world")
@ -26,5 +28,11 @@ visible = false
z_index = 5
visible = false
[node name="Pathfinder" type="Node" parent="."]
script = ExtResource("3_pathfinder")
[node name="Selection" type="Node" parent="."]
script = ExtResource("4_selection")
[node name="CameraRig" parent="." instance=ExtResource("2_camera")]
position = Vector2(640, 640)