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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue