Three gdscript-refactor agents in parallel + Opus integration.
Entities (scenes/entities/, Agent A — 3 scripts + 3 .tscn, ~460 lines):
- item.gd: 16-type StringName registry (matches design.md filter chips);
Node2D + _draw() colored square + stack-count badge; to_dict/from_dict
- tree.gd: class_name HarvestableTree (Godot 4 ships a built-in 'Tree'
Control class — renamed to avoid the shadow); CHOP_TICKS=80; on_chop_tick
advances progress, fells when complete, drops 3 wood items at tile +
walkable neighbours
- rock.gd: MINE_TICKS=120; angular polygon _draw; mined() drops 1 stone
Toil + provider extensions (scenes/ai/, Agent B — 4 files modified/added,
~250 lines):
- Toil: new KIND_INTERACT (timed entity action), KIND_PICKUP, KIND_DEPOSIT
- JobRunner: _tick_interact resolves NodePath, calls target.<method>()
each tick, marks done when is_choppable/is_mineable returns false;
_tick_pickup finds Item at pawn.tile, transfers to pawn.carried_item;
_tick_deposit places carried_item at pawn.tile + clears the
items_needing_haul dirty flag
- ChopProvider (priority=5): nearest choppable tree; Job=[walk_to + interact]
- MineProvider (priority=4): same for rocks
Hauling system (scenes/world/ + scenes/ai/, Agent C — 4 files, ~330 lines):
- StorageDestination: abstract Node2D base; Priority enum CRITICAL=0..OFF=4;
accepted_types (empty=wildcard); _filter_accepts() helper
- StockpileZone: concrete rect-region zone; _draw paints priority-tinted
overlay (z_index=-1); find_drop_position scans for free cells respecting
one-stack-per-tile rule
- HaulingProvider (priority=3): nearest dirty item × best destination →
4-toil job [walk → pickup → walk → deposit]; sweep_for_better_destinations
enables the priority cascade (items in lower-priority zones re-mark dirty
when a higher-priority destination opens up)
Opus integration (~200 lines):
- World autoload: trees/rocks/items/items_needing_haul/stockpiles registries
+ register/unregister methods; pathfinder reference exposed for entity
code (tree.fell needs is_walkable for neighbour drops)
- Pawn: carried_item slot + carry-indicator (small colored rect upper-right
of body) via queue_redraw in _on_sim_tick
- World scene: registers chop/mine/haul/rest providers; spawns 6 trees
(cluster east-north), 4 rocks (south-east), 2 stockpile zones (Zone A
wood-only NORMAL, Zone B wildcard HIGH); periodic
hauling_provider.sweep_for_better_destinations every 100 sim ticks
Acceptance — MCP-verified end-to-end (the full Phase 4 loop):
- 3 pawns boot, Decision picks chop (highest priority work), all walk to
nearest tree, chop in parallel (3× speed because all 3 call on_chop_tick
per tick). Trees fell, drop wood (18 items). Pawns move to rocks, mine,
drop stone (4 items). Total 22 items spawn.
- HaulingProvider routes wood + stone toward Zone B (wildcard HIGH > Zone
A's wood-only NORMAL). Pawns carry items one at a time, visual indicator
shows during transit. Items deposit, items_needing_haul dirty flag
clears.
- **Priority cascade test:** Zone A promoted from NORMAL to CRITICAL.
Manually-triggered sweep marks 3 wood items in Zone B for re-haul.
Within a few thousand ticks: Zone A has 5 wood (cascaded from Zone B),
Zone B has 4 stone only (wood left, stone stayed because Zone A rejects
stone). Filter + priority cascade working exactly per design.md spec.
Phase 4 gotchas (logged in implementation.md):
- 'Tree' shadows Godot 4's built-in Tree Control class — class_name had to
be renamed to HarvestableTree. Scene/file names stayed as 'tree' since
the game concept is still 'tree'; the rename only affects code-side
type references.
- draw_colored_polygon(points, color) takes a SINGLE Color, not a
PackedColorArray. Agent C had to be reminded; draw_polygon(points, colors)
is the variant that takes per-vertex colors.
- Godot's class-name cache lags behind file changes — a full editor scan
('godot --headless --editor --quit') is needed to flush. Even after
reload_project, type-annotation assignments can fail; duck-typed
variables ('var x = scene.instantiate()') sidestep the issue.
- JobRunner's _tick_deposit had to explicitly call
World.clear_item_haul_flag — the dirty set persisted otherwise and
items appeared 'needing haul' even after deposit.
Delegation report this phase:
- Agent A (Sonnet, gdscript-refactor): Tree + Rock + Item entities + i18n
keys. ~460 lines.
- Agent B (Sonnet, gdscript-refactor): Toil extensions + JobRunner handlers
+ ChopProvider + MineProvider. ~250 lines.
- Agent C (Sonnet, gdscript-refactor): StorageDestination + StockpileZone
+ HaulingProvider with cascade sweep. ~330 lines.
- Opus: World autoload extensions (entity registries + pathfinder ref),
Pawn carry slot + visual, world.tscn/gd wiring, the Tree rename, the
draw_colored_polygon fix, the dirty-set-clear fix, MCP-driven runtime
verification including the full chop-mine-haul loop and the priority
cascade demo.
~75% of Phase 4's GDScript was subagent-authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
8.1 KiB
GDScript
226 lines
8.1 KiB
GDScript
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.
|
||
##
|
||
## Phase 3 additions:
|
||
## - `forced_job` slot (player override via Selection)
|
||
## - `job_runner` Node child wired externally by the World scene
|
||
## - On each sim tick: orchestrate AI first (Decision → JobRunner.tick), then
|
||
## advance the walk. The walk is still owned by the Pawn — JobRunner's WALK
|
||
## toil delegates to `walk_along_path()` and listens for `walk_completed`.
|
||
## - to_dict() / from_dict() round-trip the entire mid-walk + mid-toil state
|
||
## (architecture.md "Save format" — mid-tick suspend safe).
|
||
|
||
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
|
||
|
||
# Player override slot — set by Selection; consumed by Decision on next sim tick.
|
||
# Untyped to dodge the autoload-class-name-ordering trap (Phase 2 gotcha).
|
||
var forced_job = null
|
||
|
||
# JobRunner node ref. Set externally by World during pawn spawn (so the runner
|
||
# can be paired with the pathfinder). May be null in tests / pre-Phase-3 scenes.
|
||
var job_runner = null
|
||
|
||
# Phase 4 — carry slot for hauling. Holds an Item node while carrying; null
|
||
# when empty-handed. PICKUP toil sets this; DEPOSIT clears it. One stack /
|
||
# one type at a time per design.md.
|
||
var carried_item = null
|
||
|
||
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
|
||
|
||
|
||
# ── save / load ─────────────────────────────────────────────────────────────
|
||
|
||
func to_dict() -> Dictionary:
|
||
var path_data: Array = []
|
||
for v in _path:
|
||
path_data.append([v.x, v.y])
|
||
return {
|
||
"name": pawn_name,
|
||
"tile_x": tile.x,
|
||
"tile_y": tile.y,
|
||
"path": path_data,
|
||
"step_progress": _step_progress,
|
||
"selected": _selected,
|
||
"forced_job": forced_job.to_dict() if forced_job != null else null,
|
||
"job_runner": job_runner.to_dict() if job_runner != null else null,
|
||
}
|
||
|
||
|
||
func from_dict(d: Dictionary) -> void:
|
||
pawn_name = d.get("name", "")
|
||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||
|
||
_path.clear()
|
||
for entry in d.get("path", []):
|
||
if entry is Array and entry.size() == 2:
|
||
_path.append(Vector2i(int(entry[0]), int(entry[1])))
|
||
_step_progress = float(d.get("step_progress", 0.0))
|
||
_selected = bool(d.get("selected", false))
|
||
|
||
var fj_dict: Variant = d.get("forced_job")
|
||
forced_job = Job.from_dict(fj_dict) if fj_dict is Dictionary else null
|
||
|
||
var jr_dict: Variant = d.get("job_runner")
|
||
if jr_dict is Dictionary and job_runner != null:
|
||
job_runner.from_dict(jr_dict)
|
||
|
||
_name_label.text = pawn_name
|
||
_state_label.text = Strings.t(&"pawn.state.walking") if is_walking() else Strings.t(&"pawn.state.idle")
|
||
position = _tile_to_world(tile)
|
||
queue_redraw()
|
||
Audit.log("pawn", "%s restored at %s (walking=%s, path len=%d)" % [pawn_name, tile, is_walking(), _path.size()])
|
||
|
||
|
||
# ── sim tick: orchestrate AI, then advance walk ─────────────────────────────
|
||
|
||
func _on_sim_tick(_tick_number: int) -> void:
|
||
_orchestrate_ai()
|
||
_advance_walk()
|
||
# Phase 4 — the carry indicator changes when PICKUP/DEPOSIT toils mutate
|
||
# carried_item directly. Cheapest reliable redraw hook is here.
|
||
queue_redraw()
|
||
|
||
|
||
func _orchestrate_ai() -> void:
|
||
# Phase 3: ask Decision for a job when the pawn is idle OR when a forced job
|
||
# is queued (forced_job preempts the current job — player override semantics).
|
||
# Decision's layer 2 consumes the forced_job slot; layer 4 falls back to work
|
||
# providers when no override is queued.
|
||
if job_runner == null:
|
||
return
|
||
if forced_job != null or not job_runner.has_job():
|
||
var next_job = Decision.pick_next_job(self, World.work_providers)
|
||
if next_job != null:
|
||
job_runner.start_job(next_job)
|
||
# Tick the runner (a freshly-started job's first toil executes here in the
|
||
# same sim tick — WALK calls pawn.walk_along_path so _advance_walk below
|
||
# immediately starts moving on this tick).
|
||
if job_runner.has_job():
|
||
job_runner.tick()
|
||
|
||
|
||
func _advance_walk() -> 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)
|
||
|
||
# Phase 4 — carry indicator: small coloured square at upper-right of body.
|
||
if carried_item != null:
|
||
var ci_hue := float(carried_item.item_type.hash() % 360) / 360.0
|
||
var ci_color := Color.from_hsv(ci_hue, 0.6, 0.85)
|
||
draw_rect(Rect2(6, -10, 7, 7), ci_color)
|
||
draw_rect(Rect2(6, -10, 7, 7), Color(0, 0, 0, 0.7), false, 1.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
|
||
)
|