rimlike/scenes/world/crate.gd
megaproxy f82807ff3d Phase 5 — Designation, BuildJob, Wall/Floor/Door, Crate; 3/4 perspective pivot
Three gdscript-refactor agents in parallel + Opus integration.

Architectural pivot (memory.md Decisions table updated):
- View: top-down grid for gameplay + 3/4-perspective rendering for vertical
  structures (Stardew/Going Medieval style). Walls/doors/crates are Y-sorted
  entity sprites, not TileMap cells.
- Wall TileMap layer (Layer 2) becomes data-only — used for room detection,
  roof BFS, save serialization. Visual rendering happens at entity level.
- Asset reality check baked into the decision: the entire asset library is
  RPG-style perspective art; pivoting the renderer is cheaper than authoring
  or commissioning top-down 47-tile autotile sets.

Designation paint (scenes/world/, Agent A — ~170 lines):
- class_name Designation extends Node, lives as DesignationCtl child of World
- TOOL_NONE / TOOL_BUILD_WALL / TOOL_BUILD_FLOOR
- _unhandled_input captures left-mouse press/drag/release
- Drag-paints ghost tiles on Layer 3 via paint_layer.set_cell
- Green/red modulate based on World.pathfinder.is_walkable + cell occupancy
- Emits EventBus.designation_added/cleared per cell
- Selection.designation_active guard prevents double-handling clicks

EventBus signals added:
- designation_added(cell: Vector2i, tool: StringName)
- designation_cleared(cell: Vector2i)

BuildJob + Wall/Floor/Door entities (scenes/ai/ + scenes/entities/, Agent B — ~530 lines):
- Toil.KIND_BUILD + Toil.build_at(target_path) factory
- JobRunner._tick_build: resolves NodePath target, calls on_build_tick() per
  sim tick, marks toil done when is_buildable() returns false
- ConstructionProvider (priority=6, highest): nearest is_buildable() site in
  World.build_queue → Job=[walk_to(site.tile), build_at(site.get_path())]
- Wall entity: BUILD_TICKS=100, 40% alpha ghost; _complete() calls
  pathfinder.set_cell_walkable(false) + World.mark_wall_tile + Audit.log
- Floor entity: BUILD_TICKS=30, ground-level (no y_sort), does NOT block
  pathfinding, calls mark_floor_tile on complete
- Door entity: BUILD_TICKS=80, bottom-anchored, walkable when built (no
  pathfinder block); registers with World.doors for Phase 7 open/close logic
- ALL wall/door scenes have y_sort_enabled=true on root; floors don't (always
  on ground plane)

Crate furniture (scenes/world/, Agent C — ~270 lines):
- class_name Crate extends StorageDestination (Phase 4's abstract base)
- CAPACITY=4 stacks; accepts() gates on _completed + _filter_accepts + room
- find_drop_position returns tile when room exists, (-1,-1) otherwise
- BUILD_TICKS=60; on_build_tick mirrors Wall's pattern
- _draw procedural brown crate body + slat lines + fill-level dots

World autoload extensions (Opus):
- build_queue: Array — Wall/Floor/Door/Crate ghost entities awaiting
  construction. ConstructionProvider iterates by .priority desc; Phase 6+
  prepends material-haul toils.
- doors: Array — completed doors for future open/close (Phase 7+)
- wall_layer / floor_layer / designation_layer refs exposed for entity code
- mark_wall_tile(tile, material) / mark_floor_tile(tile, material) —
  stamps data-only TileMap layer with material-encoded atlas coord
- stockpile_at_tile(tile) — finds StockpileZone OR Crate covering a tile;
  used by JobRunner._tick_deposit to route Crate deposits

JobRunner._tick_deposit refactor (Opus):
- After clearing the haul-dirty flag, looks up stockpile_at_tile(pawn.tile)
- If destination is a Crate (has_method('register_item')), calls
  dest.register_item(item) so the crate's _contents tracks the stack

World scene integration (Opus):
- y_sort_enabled=true on World root so all entity sprites sort correctly
- DesignationCtl, ConstructionProvider, Wall TileMap (data-only, visible=false)
- World._ready wires:
  * World.wall_layer / floor_layer / designation_layer
  * designation.bind(designation_layer, selection)
  * Register 5 work providers (construction=6 > chop=5 > mine=4 > haul=3 > rest=0)
  * EventBus.designation_added → _on_designation_added (spawns Wall/Floor entity)
- _seed_phase5_demo_buildings: pre-queues 14 wall designations forming a
  5×4 cabin outline at (45, 25) so pawns visibly construct walls without
  player-paint UI (deferred to Phase 17). Spawns 2 fully-built crates at
  (17-18, 60) for hauling routing.

Acceptance — MCP-verified end-to-end:
- 14 wall designations seeded at boot, 2 crates pre-built
- All 3 pawns picked construction (highest priority work) and walked to
  build sites (paths 37/32/27 from spawn). Walls built one by one.
- Wall layer post-construction has 42 cells: 28 (Phase 1 stone ring) +
  14 (Phase 5 cabin) — both rendering paths (placeholder TileMap from
  Phase 1, plus new entity sprites from Phase 5) coexist correctly.
- Pathfinder set_cell_walkable(false) fired on each wall completion.
- Pawns transitioned from construction to hauling once all walls done.
- Final visual: 5×4 stone-walled cabin with mortar lines, Y-sorted entity
  rendering, wood items scattered east of the cabin awaiting haul.

Phase 5 gotchas (logged):
- 'material' as a member var shadows CanvasItem.material (Node2D inherits
  it). Renamed to wall_material / floor_material via quick-edit agent.
  Save-format dict KEYS stay as 'material' for stability.
- Class-name registration cache lag bit again (Tree/Pawn pattern from
  earlier phases). Workflow stays: agent writes class_name file → MCP
  reload_project → godot --headless --editor --quit → headless validate.
- ConstructionProvider scans build_queue every tick including completed
  walls; is_buildable() filters them out but the queue keeps growing.
  Phase 16+ should add an unregister_build_site call from _complete or
  a periodic queue compaction.

Delegation report this phase:
- Agent A (Sonnet, gdscript-refactor): Designation paint mode + EventBus
  signals + Selection guard. ~180 lines.
- Agent B (Sonnet, gdscript-refactor): Toil.KIND_BUILD + JobRunner._tick_build
  + ConstructionProvider + Wall/Floor/Door entities + scenes. ~530 lines.
- Agent C (Sonnet, gdscript-refactor): Crate furniture extending
  StorageDestination. ~270 lines.
- quick-edit (Haiku): material → wall_material/floor_material rename. ~14
  occurrences across 2 files.
- Opus: World autoload extensions + JobRunner _tick_deposit refactor +
  World scene integration (DesignationCtl + ConstructionProvider + new
  scene preloads + _seed_phase5_demo_buildings) + MCP runtime verification
  + the material-shadow + class-cache-lag debugging.

Pivot decision worth flagging: the asset library audit revealed that no
pack we own ships top-down 47-tile autotile walls. After multiple
researcher-overpromise cycles, the pragmatic call was to pivot the
rendering model itself. Walls now render as bottom-anchored tall sprites
with Y-sort; the entire asset library becomes usable as-is. Phase 17
polish will swap procedural _draw() with AtlasTexture regions from
Pixel Crawler / FG_Houses / Ventilatore Castle_Building.

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

268 lines
10 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 Crate extends StorageDestination
## Furniture container entity: holds up to CAPACITY item stacks and can be
## filtered like a StockpileZone. Built via Build → Furniture → Crate.
##
## Lifecycle:
## - Ghost phase: placed but not yet built (build_progress < BUILD_TICKS).
## accepts() returns false; visual is 40% alpha.
## - Completed phase: _completed == true; accepts items up to CAPACITY.
##
## StorageDestination interface:
## accepts() — filter + capacity gate; false while ghost.
## find_drop_position()— returns crate's own tile when room exists,
## Vector2i(-1, -1) when full or ghost.
## covers_tile() — single-tile container; only the crate's own tile.
##
## BuildJob interface (mirrors Wall.on_build_tick pattern):
## is_buildable() — true while still a ghost.
## on_build_tick() — increments build_progress; completes at BUILD_TICKS.
## is_completed() — true once built.
##
## register_item() is called by JobRunner._tick_deposit (Opus integration
## follow-up) after the deposit physically lands on the crate tile.
##
## See docs/architecture.md "Container" and Phase 5 implementation plan.
## Maximum item stacks this crate can hold.
const CAPACITY: int = 4
## Number of sim ticks a pawn must spend building to complete the crate.
const BUILD_TICKS: int = 60
## Pixel size of one tile — must match World.TILE_SIZE_PX.
const TILE_SIZE_PX: int = 16
# ── visual constants ──────────────────────────────────────────────────────────
## Body dimensions in pixels (centred on the tile).
const _BODY_W: int = 12
const _BODY_H: int = 10
## Crate colours.
const _COLOR_BODY: Color = Color(0.45, 0.30, 0.15, 1.0)
const _COLOR_OUTLINE: Color = Color(0.25, 0.18, 0.08, 1.0)
const _COLOR_SLAT: Color = Color(0.30, 0.20, 0.10, 1.0)
const _COLOR_FILL_DOT: Color = Color(1.0, 1.0, 1.0, 0.9)
## Ghost alpha multiplier when not yet built.
const _GHOST_ALPHA: float = 0.40
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this crate in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-facing label (inspect UI, Phase 17).
@export var label_text: String = "Crate"
# ── state ─────────────────────────────────────────────────────────────────────
## Sim ticks of construction work applied so far.
var build_progress: int = 0
## True once build_progress >= BUILD_TICKS.
var _completed: bool = false
## Live item nodes currently stored in this crate; capped at CAPACITY.
## Populated by register_item() (called from JobRunner._tick_deposit).
var _contents: Array = []
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Inherits StorageDestination defaults for priority / accepted_types.
# Crates default to NORMAL priority and wildcard (accepts all types).
priority = StorageDestination.Priority.NORMAL
accepted_types = []
# Register with the World stockpile pool so HaulingProvider sees us.
World.register_stockpile(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_stockpile(self)
## One-shot initialiser called by the spawning / placement code.
## Sets tile and snaps pixel position to the tile centre.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)
# ── StorageDestination interface ──────────────────────────────────────────────
## Returns true if this crate can accept `item` right now.
## False while still a ghost, false if the filter rejects the type,
## false if all CAPACITY slots are taken.
func accepts(item) -> bool:
if not _completed:
return false
if not _filter_accepts(item):
return false
return _contents.size() < CAPACITY
## Returns the crate's own tile when it can accept `item`, otherwise (-1,-1).
## All items stack into the crate's single tile — there is no 2D region.
func find_drop_position(item) -> Vector2i:
if accepts(item):
return tile
return Vector2i(-1, -1)
## Returns true only when `p_tile` is exactly the crate's own tile.
func covers_tile(p_tile: Vector2i) -> bool:
return p_tile == tile
# ── BuildJob interface ────────────────────────────────────────────────────────
## True while the crate has not yet been fully built.
func is_buildable() -> bool:
return not _completed
## Returns the player-visible name for build-order and inspect UI.
func label() -> String:
return label_text
## Called once per sim tick while a Construction pawn is working on this crate.
## Advances build_progress; completes the crate once BUILD_TICKS is reached.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
if build_progress >= BUILD_TICKS:
_completed = true
emit_signal("contents_changed")
Audit.log("crate", "built at %s (capacity %d)" % [tile, CAPACITY])
queue_redraw()
## True once the crate has been fully built.
func is_completed() -> bool:
return _completed
# ── inventory hooks ───────────────────────────────────────────────────────────
## Called from JobRunner._tick_deposit (Opus integration) after the item
## physically lands on the crate tile.
## Defensive: skips duplicates and over-capacity inserts (HaulingProvider may
## race ahead of capacity checks in edge cases).
func register_item(item) -> void:
if not _completed:
return
if _contents.has(item) or _contents.size() >= CAPACITY:
return
_contents.append(item)
emit_signal("contents_changed")
## Called when an item is removed from the crate (picked up by a pawn or via
## the Empty operation in Phase 17 inspect UI).
func unregister_item(item) -> void:
_contents.erase(item)
emit_signal("contents_changed")
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise crate state for World save (Phase 16 will wire this).
func to_dict() -> Dictionary:
return {
"tile_x": tile.x,
"tile_y": tile.y,
"label_text": label_text,
"build_progress": build_progress,
"completed": _completed,
"priority": int(priority),
"accepted_types": accepted_types.map(func(t): return String(t)),
}
## Restore from a dict produced by to_dict().
## Item content refs are reconnected by World.load_crates() after all items
## are spawned (Phase 16); _contents starts empty here.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
label_text = d.get("label_text", "Crate")
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
priority = d.get("priority", StorageDestination.Priority.NORMAL) as StorageDestination.Priority
var raw_types: Array = d.get("accepted_types", [])
accepted_types.clear()
for s in raw_types:
accepted_types.append(StringName(s))
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
## Procedural crate graphic; no PNG dependency.
##
## Completed crate:
## Brown 12×10 body with a darker 1-px outline, two horizontal slat bands.
## Four 2×2 fill-indicator dots in the top-right corner — white dots equal
## to _contents.size() are drawn; remaining dots are drawn at low alpha.
##
## Ghost (not yet built):
## Same shapes at GHOST_ALPHA overall alpha.
func _draw() -> void:
var alpha_scale: float = _GHOST_ALPHA if not _completed else 1.0
var body_color := Color(_COLOR_BODY.r, _COLOR_BODY.g, _COLOR_BODY.b, _COLOR_BODY.a * alpha_scale)
var outline_col := Color(_COLOR_OUTLINE.r, _COLOR_OUTLINE.g, _COLOR_OUTLINE.b, _COLOR_OUTLINE.a * alpha_scale)
var slat_color := Color(_COLOR_SLAT.r, _COLOR_SLAT.g, _COLOR_SLAT.b, _COLOR_SLAT.a * alpha_scale)
# Half-extents for centering.
var hw: int = _BODY_W / 2 # 6
var hh: int = _BODY_H / 2 # 5
# Body fill.
var body_rect := Rect2(Vector2(-hw, -hh), Vector2(_BODY_W, _BODY_H))
draw_rect(body_rect, body_color, true)
# Outline (1 px wide; draw_rect with false = border).
draw_rect(body_rect, outline_col, false, 1.0)
# Two horizontal slat bands — at ⅓ and ⅔ of the body height.
# Each band is 1 px tall, inset 1 px from the sides.
var slat_x_start: float = -hw + 1.0
var slat_width: float = float(_BODY_W - 2)
var slat_y1: float = -hh + float(_BODY_H) / 3.0 - 0.5
var slat_y2: float = -hh + float(_BODY_H) * 2.0 / 3.0 - 0.5
draw_line(
Vector2(slat_x_start, slat_y1),
Vector2(slat_x_start + slat_width, slat_y1),
slat_color, 1.0
)
draw_line(
Vector2(slat_x_start, slat_y2),
Vector2(slat_x_start + slat_width, slat_y2),
slat_color, 1.0
)
# Fill-level indicator: 4 dots (2×2 px each) in the top-right corner,
# arranged in a 2×2 grid. Dots up to _contents.size() are bright white;
# the rest are dim (10% alpha).
var dot_size: float = 2.0
var dot_gap: float = 1.0
var dot_origin := Vector2(float(hw) - 2.0 * dot_size - dot_gap - 1.0, float(-hh) + 1.0)
for i in range(CAPACITY):
var col_idx: int = i % 2
var row_idx: int = i / 2
var dot_x: float = dot_origin.x + col_idx * (dot_size + dot_gap)
var dot_y: float = dot_origin.y + row_idx * (dot_size + dot_gap)
var dot_rect := Rect2(Vector2(dot_x, dot_y), Vector2(dot_size, dot_size))
var fill_alpha: float = (1.0 if i < _contents.size() else 0.15) * alpha_scale
var dot_col := Color(_COLOR_FILL_DOT.r, _COLOR_FILL_DOT.g, _COLOR_FILL_DOT.b, fill_alpha)
draw_rect(dot_rect, dot_col, true)