rimlike/scenes/world/world.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

339 lines
13 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.

extends Node2D
## Phase 4 world view. 80×80 TileMap, 6 layers, 3 pawns, full AI pipeline:
## RestProvider → ChopProvider → MineProvider → HaulingProvider → idle
## plus sample trees, rocks, and two stockpile zones with different priorities
## for the haul-cascade demo.
##
## TileMap layer indices follow docs/architecture.md:
## 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof · 5 Fog
const MAP_SIZE_TILES: Vector2i = Vector2i(80, 80)
const TILE_SIZE_PX: int = 16
const TILE_GRASS: Vector2i = Vector2i(0, 0)
const TILE_DIRT: Vector2i = Vector2i(1, 0)
const TILE_STONE: Vector2i = Vector2i(2, 0)
const TILE_STONE_DARK: Vector2i = Vector2i(3, 0)
const PLACEHOLDER_SOURCE_ID: int = 0
const PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn")
const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn")
const ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn")
const STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn")
const WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn")
const FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn")
const DOOR_SCENE: PackedScene = preload("res://scenes/entities/door.tscn")
const CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.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)},
]
# Phase 4 — sample harvestables. Trees clustered east, rocks south-east.
const SAMPLE_TREES: Array[Vector2i] = [
Vector2i(58, 30), Vector2i(60, 31), Vector2i(62, 30),
Vector2i(61, 33), Vector2i(63, 34), Vector2i(59, 35),
]
const SAMPLE_ROCKS: Array[Vector2i] = [
Vector2i(60, 60), Vector2i(62, 60), Vector2i(63, 62), Vector2i(58, 62),
]
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
@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
@onready var designation_ctl: Designation = $DesignationCtl
@onready var rest_provider: RestProvider = $RestProvider
@onready var chop_provider: ChopProvider = $ChopProvider
@onready var mine_provider: MineProvider = $MineProvider
@onready var hauling_provider: HaulingProvider = $HaulingProvider
@onready var construction_provider: ConstructionProvider = $ConstructionProvider
func _ready() -> void:
Audit.log("world", "Phase 4 — building %d×%d world + harvestables + AI." % [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
_paint_terrain()
_paint_sample_walls()
_apply_camera_bounds()
pathfinder.setup(MAP_SIZE_TILES)
_wire_walls_to_pathfinder()
selection.bind(pathfinder)
World.pathfinder = pathfinder # expose to entities (Tree.fell() walkability checks, etc.)
# Phase 5 — expose TileMap layer refs on the autoload so entity code
# (Wall._complete, Floor._complete) can stamp data-only tile state.
World.wall_layer = wall_layer
World.floor_layer = floor_layer
World.designation_layer = designation_layer
# Designation: bind the paint surface + the Selection guard.
designation_ctl.bind(designation_layer, selection)
# Register all 5 providers — Decision iterates by .priority desc.
# construction=6 > chop=5 > mine=4 > haul=3 > rest=0.
World.register_work_provider(construction_provider)
World.register_work_provider(chop_provider)
World.register_work_provider(mine_provider)
World.register_work_provider(hauling_provider)
World.register_work_provider(rest_provider)
# Phase 5: bridge designation paint events → spawn the ghost-state entity
# at that tile and register it as a build site.
EventBus.designation_added.connect(_on_designation_added)
EventBus.designation_cleared.connect(_on_designation_cleared)
_spawn_sample_pawns()
_spawn_sample_harvestables()
_spawn_sample_stockpiles()
_seed_phase5_demo_buildings()
_run_pathfinder_spike()
# Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in
# stockpiles in case a higher-priority destination opened up.
EventBus.sim_tick.connect(_on_sim_tick_world_sweep)
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. Real ElvGames
# art replaces this in Phase 5 (wood walls + stone walls).
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
var atlas_w := TILE_SIZE_PX * 4
var img := Image.create(atlas_w, TILE_SIZE_PX, false, Image.FORMAT_RGBA8)
var palette: Array[Color] = [
Color(0.45, 0.65, 0.30), # grass
Color(0.55, 0.45, 0.30), # dirt
Color(0.60, 0.60, 0.55), # stone
Color(0.30, 0.30, 0.32), # stone dark
]
for i in palette.size():
var base: Color = palette[i]
var border: Color = base.darkened(0.15)
for px in TILE_SIZE_PX:
for py in TILE_SIZE_PX:
var on_border := (
px == 0 or px == TILE_SIZE_PX - 1
or py == 0 or py == TILE_SIZE_PX - 1
)
img.set_pixel(i * TILE_SIZE_PX + px, py, border if on_border else base)
var tex := ImageTexture.create_from_image(img)
var src := TileSetAtlasSource.new()
src.texture = tex
src.texture_region_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
for i in palette.size():
src.create_tile(Vector2i(i, 0))
ts.add_source(src, PLACEHOLDER_SOURCE_ID)
return ts
func _paint_terrain() -> void:
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:
var origin := Vector2i(36, 36)
var size: int = 8
for i in size:
wall_layer.set_cell(origin + Vector2i(i, 0), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(i, size - 1), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(0, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
wall_layer.set_cell(origin + Vector2i(size - 1, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
# ── pathfinder + pawns ──────────────────────────────────────────────────────
func _wire_walls_to_pathfinder() -> void:
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"])
# Phase 3: attach a JobRunner so Decision can hand it jobs.
var jr := JobRunner.new()
jr.name = "JobRunner"
p.add_child(jr)
jr.setup(p, pathfinder)
p.job_runner = jr
World.register_pawn(p)
# ── Phase 4: harvestables + stockpile zones ─────────────────────────────────
func _spawn_sample_harvestables() -> void:
# Untyped vars — Godot's class-name cache for class_name'd classes is
# scan-time and intermittently lags behind file changes. Duck typing is
# safer here and the calls below are all spec'd on the entity types.
for t_tile in SAMPLE_TREES:
var tree = TREE_SCENE.instantiate()
add_child(tree)
tree.setup(t_tile)
for r_tile in SAMPLE_ROCKS:
var rock = ROCK_SCENE.instantiate()
add_child(rock)
rock.setup(r_tile)
Audit.log("world", "spawned %d trees + %d rocks" % [SAMPLE_TREES.size(), SAMPLE_ROCKS.size()])
func _seed_phase5_demo_buildings() -> void:
# Pre-queue a small cabin outline as build designations so pawns visibly
# construct walls when they exit Chop/Mine work. Phase 17 will add real
# player-driven designation UI; for now the player sees the construction
# loop without needing to paint anything.
#
# Layout: ~5×4 wall ring at (45, 25), with an open door slot.
var origin := Vector2i(45, 25)
var w := 5
var h := 4
for x in w:
EventBus.designation_added.emit(origin + Vector2i(x, 0), Designation.TOOL_BUILD_WALL)
EventBus.designation_added.emit(origin + Vector2i(x, h - 1), Designation.TOOL_BUILD_WALL)
for y in h:
EventBus.designation_added.emit(origin + Vector2i(0, y), Designation.TOOL_BUILD_WALL)
EventBus.designation_added.emit(origin + Vector2i(w - 1, y), Designation.TOOL_BUILD_WALL)
# Place a couple of crates at known tiles so the haul system has a target.
var crate_tiles: Array[Vector2i] = [Vector2i(17, 60), Vector2i(18, 60)]
for t in crate_tiles:
var c: Crate = CRATE_SCENE.instantiate()
add_child(c)
c.setup(t)
# Mark crate as already-built for the demo (skip the construction phase).
# Phase 17 player UI flow would walk it through a build designation first.
while c.is_buildable():
c.on_build_tick()
Audit.log("world", "phase 5 demo: %d wall designations seeded + %d crates built" % [
w * 2 + h * 2 - 4, crate_tiles.size()
])
func _spawn_sample_stockpiles() -> void:
# Two zones for the Phase 4 acceptance demo:
# - Zone A (north): wood-only filter, NORMAL priority (just a wood drop)
# - Zone B (south): wildcard, HIGH priority (the "watch wood flow upward" target)
# When the sweep runs, wood items in Zone A get re-marked for haul and
# eventually migrate to Zone B.
var zone_a: StockpileZone = STOCKPILE_SCENE.instantiate()
add_child(zone_a)
zone_a.region = Rect2i(15, 55, 4, 4)
zone_a.label = "Wood (Normal)"
zone_a.priority = StorageDestination.Priority.NORMAL
zone_a.accepted_types = [Item.TYPE_WOOD] as Array[StringName]
zone_a.queue_redraw()
var zone_b: StockpileZone = STOCKPILE_SCENE.instantiate()
add_child(zone_b)
zone_b.region = Rect2i(15, 62, 4, 4)
zone_b.label = "Anything (High)"
zone_b.priority = StorageDestination.Priority.HIGH
zone_b.accepted_types = [] as Array[StringName] # wildcard
zone_b.queue_redraw()
Audit.log("world", "spawned 2 stockpiles: %s + %s" % [zone_a.label, zone_b.label])
# ── Phase 5: designation → build-site spawn bridge ──────────────────────────
# Track build sites keyed by tile so we can find + queue_free them on cancel.
var _build_sites_by_tile: Dictionary = {}
func _on_designation_added(cell: Vector2i, tool: StringName) -> void:
if _build_sites_by_tile.has(cell):
return # already a build site here
var entity = null
match tool:
&"build_wall":
entity = WALL_SCENE.instantiate()
add_child(entity)
entity.setup(cell, &"stone")
&"build_floor":
entity = FLOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell, &"wood")
_:
Audit.log("world", "unknown designation tool: %s" % tool)
return
_build_sites_by_tile[cell] = entity
Audit.log("world", "queued %s at %s" % [tool, cell])
func _on_designation_cleared(cell: Vector2i) -> void:
if not _build_sites_by_tile.has(cell):
return
var entity = _build_sites_by_tile[cell]
_build_sites_by_tile.erase(cell)
if is_instance_valid(entity) and not entity.is_completed():
entity.queue_free()
# ── periodic re-flow (the "wood floats up" cascade) ─────────────────────────
func _on_sim_tick_world_sweep(tick_n: int) -> void:
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
func _run_pathfinder_spike() -> void:
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:
Audit.log("world", "no CameraRig child yet — bounds set later when camera lands.")
return
if not cam.has_method("set_world_bounds"):
Audit.log("world", "CameraRig present but missing set_world_bounds() — skipping.")
return
cam.set_world_bounds(world_bounds_px())