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:
parent
836dfdd716
commit
cd265b87c0
13 changed files with 475 additions and 24 deletions
128
scenes/pawn/pawn.gd
Normal file
128
scenes/pawn/pawn.gd
Normal 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
1
scenes/pawn/pawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cihxqlnvbn52y
|
||||
23
scenes/pawn/pawn.tscn
Normal file
23
scenes/pawn/pawn.tscn
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue