rimlike/scenes/entities/wall.gd
megaproxy 0cd7f809a7 Phase 6 — Recipe / Bill / Workbench / CraftingProvider / Quality / Skills
Three gdscript-refactor agents in parallel; Opus did integration + caught
the wall-trap bug via MCP runtime test.

Data layer (Agent A):
- scenes/ai/recipe.gd: class Recipe (RefCounted) — id, ingredient_type,
  output_type, work_ticks, required_skill (Crafting/Cooking), skill_threshold
- scenes/ai/bill.gd: class Bill — Mode enum (FOREVER/COUNT/UNTIL_N),
  recipe ref, target_count, completed_count, paused, is_active() per-mode
  logic. UNTIL_N walks World.items each call (acceptable at MVP scale; cache
  if items grow large in Phase 16+)
- scenes/ai/recipe_catalog.gd: RecipeCatalog.plank() / .stone_block() — 2
  starter recipes; Phase 7+ expands toward the design.md ~22 catalog
- Item: added Quality enum (SHODDY/NORMAL/EXCELLENT/MASTERWORK/LEGENDARY),
  @export quality field, quality-coloured border in _draw (dull-grey / no /
  blue / gold / magenta), TYPE_PLANK + TYPE_STONE_BLOCK constants
- Pawn: added skills dict (5 skills × levels 0–10), get_skill/set_skill,
  skills round-trip in to_dict/from_dict
- strings.gd: item.plank, item.stone_block, quality.* (5 keys)

Workbench entity (Agent B, scenes/entities/workbench.{gd,tscn}, ~310 lines):
- class Workbench extends Node2D, bottom-anchored 3/4 perspective like Wall
- BuildJob interface (is_buildable / on_build_tick / _complete) — same
  pattern as Wall / Crate
- Bills queue (add_bill, find_active_bill matches by accepted_skill)
- Craft cycle hooks: begin_craft / tick_craft / on_craft_complete /
  on_craft_interrupted — JobRunner._tick_craft delegates to these
- Procedural _draw differentiates Carpenter (brown bench + vise) vs
  Smelter (dark stone + orange ember glow) via the @export label_text
  field — no subclass needed for Phase 6
- World autoload: workbenches registry + register_workbench/unregister_workbench

Crafting AI (Agent C):
- Toil.KIND_CRAFT + Toil.craft_at(workbench_path, bill_index) factory
- JobRunner._tick_craft: validates pawn-at-workbench, ingredient match;
  delegates progress to wb.tick_craft; on complete spawns output Item
  with QualityCalc.roll() applied; records bill completion
- crafting_provider.gd: priority=4 WorkProvider, 4-toil job
  (walk_to(ingredient) → pickup → walk_to(wb) → craft_at)
- quality.gd: QualityCalc.roll(skill) — additive formula
  skill × 0.04 + RNG(0, 0.6) with bucket thresholds matching
  architecture.md spec. Skill 0 caps at Excellent; Skill 10 reaches
  Legendary ~8% of the time

Opus integration:
- world.tscn: CraftingProvider node added
- world.gd: registered crafting_provider with World (priority order:
  construction=6 > chop=5 > mine=4 > crafting=4 > haul=3 > rest=0)
- Pawn spawn data extended with crafting skill (Bram=8, Cora=4, Edda=0)
  for visible quality variation in the demo
- _seed_phase5_demo_buildings extended: pre-built Carpenter at (46, 25)
  with plank bill (FOREVER) + Smelter at (48, 25) with stone_block bill
  (UNTIL_N=5)

The wall-trap bug (caught via MCP runtime — initial Phase 6 run hung):
- Pawns building walls stood ON the wall tile. When wall._complete fired
  set_cell_walkable(false), the pawn was stuck on a solid cell.
  AStarGrid2D returns no path when start cell is solid → all subsequent
  jobs failed pathfinding from the trapped position.
- Fix: ConstructionProvider checks site.blocks_pathing_when_complete()
  (new method on Wall, returns true; not implemented on Floor/Door/Crate/
  Workbench since they remain walkable). Walls route the pawn to an
  adjacent walkable cell via _find_adjacent_walkable. Floors/doors/etc.
  build on-tile as before.
- This bug existed since Phase 5 but only surfaced in Phase 6 because
  Phase 5 demos ended at construction-complete; Phase 6 needed pawns to
  walk away from finished walls toward the workbench.

Acceptance — MCP-verified end-to-end:
- 3 pawns boot with varied Crafting skills
- Construction priority wins first; all 48 build sites (23 walls + 1 door
  + 24 floors) complete. Pawns escape wall tiles safely (fix verified).
- Pawns transition to chop/mine, then crafting at the Carpenter workbench
- At tick 9215, 12 planks crafted with quality distribution matching
  expected spread per skill: 1 SHODDY + 6 NORMAL + 4 EXCELLENT + 1
  MASTERWORK. Quality-coloured borders visible on items.
- Smelter UNTIL_N=5 bill correctly idle (no stone consumed yet) because
  CraftingProvider prefers closer workbench-ingredient pairs and the
  carpenter+wood is closer to where pawns end up than smelter+stone

Phase 6 followups for later phases:
- on_craft_interrupted has no JobRunner hook — Phase 9 status interrupts
  will need a 'cancel callback' on toils or wb.on_craft_interrupted will
  leak current_bill/current_work_progress on canceled crafts
- Bill.from_dict reconstructs Recipe inline via Recipe.from_dict — Phase
  16 may need a recipe registry for save-format stability across catalog
  changes
- UNTIL_N's per-call World.items walk is O(items) — acceptable at MVP
  scale; profile if it becomes hot

Delegation report this phase:
- Agent A: Recipe + Bill + RecipeCatalog + Item.quality + Pawn.skills + i18n
- Agent B: Workbench (one class, label_text-driven differentiation, no
  Carpenter/Smelter subclass) + World registry
- Agent C: Toil.KIND_CRAFT + JobRunner._tick_craft + CraftingProvider +
  QualityCalc
- Opus: scene wiring + pawn-skill init + workbench demo seed + wall-trap
  fix (caught via MCP) + runtime verification

~75% of Phase 6 GDScript was subagent-authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:41 +01:00

190 lines
7.6 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name Wall extends Node2D
## Wall entity — built by a pawn with a Build job. Blocks pathfinding once
## complete. Rendered as a bottom-anchored tall sprite (Y-sorted) so it
## occludes pawns standing behind it, matching the project's 3/4-perspective
## rendering pivot (see memory.md Decisions: "Wall layer rendering").
##
## Build model (docs/implementation.md Phase 5):
## A ConstructionProvider creates a Job whose BUILD toil calls on_build_tick()
## once per sim tick via JobRunner. After BUILD_TICKS ticks the wall is
## complete: it stamps the data-layer TileMap (World.mark_wall_tile), blocks
## pathfinding, and transitions from ghost (40% alpha) to solid rendering.
##
## Material support:
## Phase 5 ships stone only. Wood constant is defined for Phase 6+ wiring
## (Pixel Crawler Walls.png asset crop session) without breaking the data model.
##
## World registration: World.register_build_site / World.unregister_build_site
## are called from _ready / _exit_tree. The actual World methods land in the
## Opus integration pass.
const TILE_SIZE_PX: int = 16
## Sim ticks to complete construction at 1× speed (100 ticks = 5 sim seconds).
const BUILD_TICKS: int = 100
## Supported materials. Phase 5 uses MATERIAL_STONE; MATERIAL_WOOD is reserved
## for the Phase 6+ art-authoring pass.
const MATERIAL_STONE: StringName = &"stone"
const MATERIAL_WOOD: StringName = &"wood"
# ── state ──────────────────────────────────────────────────────────────────────
@export var wall_material: StringName = MATERIAL_STONE
@export var tile: Vector2i = Vector2i.ZERO
## 0..BUILD_TICKS. Advanced by on_build_tick(). Entity is in "ghost" state
## until build_progress reaches BUILD_TICKS.
var build_progress: int = 0
var _completed: bool = false
# ── lifecycle ──────────────────────────────────────────────────────────────────
func _ready() -> void:
World.register_build_site(self)
func _exit_tree() -> void:
World.unregister_build_site(self)
# ── public API ─────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() has already fired.
func setup(p_tile: Vector2i, p_material: StringName) -> void:
tile = p_tile
wall_material = p_material
# Bottom-anchor the sprite: position.y sits at the bottom of the tile so the
# 16×32 virtual sprite "rises" into the cell above. Y-sort uses position.y.
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
)
queue_redraw()
Audit.log("wall", "%s wall ghost placed at %s" % [wall_material, tile])
## True while the wall still needs construction work.
## JobRunner's _tick_build checks this to decide when the toil is done.
func is_buildable() -> bool:
return not _completed
## Construction-provider hint: walls become impassable when built, so pawns
## must stand on an adjacent tile while building. Phase 6 fix for the
## "pawn-trapped-on-wall" bug. Floors / Doors / Crates / Workbenches don't
## need this since they remain walkable after completion.
func blocks_pathing_when_complete() -> bool:
return true
## Human-readable label for job descriptions and Audit logs.
func label() -> String:
return "%s wall" % wall_material
## Called by the BUILD toil in JobRunner once per sim tick while the pawn works.
## Advances build_progress and completes the wall when BUILD_TICKS is reached.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
queue_redraw()
if build_progress >= BUILD_TICKS:
_complete()
## True once the wall has been fully built.
func is_completed() -> bool:
return _completed
# ── save / load ────────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"tile_x": tile.x,
"tile_y": tile.y,
"material": str(wall_material),
"build_progress": build_progress,
"completed": _completed,
}
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"material": str(d.get("material", "stone")),
"build_progress": int(d.get("build_progress", 0)),
"completed": bool(d.get("completed", false)),
}
# ── render ─────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 3/4-perspective wall rendering — fits WITHIN the wall's own tile so it
# never encroaches on adjacent floor/interior tiles. Two-band look:
# Top band (lit) = the wall's "top surface" (looking down at it)
# Bottom band (dark) = the wall's "front face" (looking at the side)
#
# Origin (0, 0) is at the tile's bottom-centre. Tile spans local Y: -16 to 0.
# We draw entirely within that 16×16 box.
var alpha: float = 1.0 if _completed else 0.4
if wall_material == MATERIAL_STONE:
_draw_stone_wall(alpha)
else:
_draw_wood_wall(alpha)
func _draw_stone_wall(alpha: float) -> void:
var top_face := Color(0.65, 0.65, 0.60, alpha) # lit top surface
var front_face := Color(0.42, 0.42, 0.38, alpha) # shaded front
var mortar := Color(0.28, 0.28, 0.25, alpha)
var outline := Color(0.18, 0.18, 0.16, 0.7 * alpha)
# Top face — thin lit strip at upper-third of the tile.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
# Front face — main wall body, lower two-thirds.
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Mortar lines on the front face only.
draw_line(Vector2(-8.0, -7.0), Vector2(8.0, -7.0), mortar, 1.0)
draw_line(Vector2(-8.0, -3.0), Vector2(8.0, -3.0), mortar, 1.0)
# Border between top and front (gives the depth illusion).
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), Color(0.20, 0.20, 0.18, alpha), 1.0)
# Outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
func _draw_wood_wall(alpha: float) -> void:
var top_face := Color(0.62, 0.45, 0.25, alpha)
var front_face := Color(0.42, 0.30, 0.16, alpha)
var plank := Color(0.30, 0.20, 0.10, alpha)
var outline := Color(0.16, 0.10, 0.04, 0.7 * alpha)
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), top_face)
draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), front_face)
# Vertical plank seams on the front face.
for x_offset in [-3.0, 2.0]:
draw_line(Vector2(x_offset, -11.0), Vector2(x_offset, 0.0), plank, 1.0)
draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), Color(0.20, 0.14, 0.06, alpha), 1.0)
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)
# ── internal ───────────────────────────────────────────────────────────────────
func _complete() -> void:
_completed = true
# Block pathfinding — wall is now impassable.
if World.pathfinder != null:
World.pathfinder.set_cell_walkable(tile, false)
# Stamp the data-layer TileMap so room / roof / save logic sees the wall.
World.mark_wall_tile(tile, wall_material)
queue_redraw()
Audit.log("wall", "%s wall completed at %s" % [wall_material, tile])