rimlike/scenes/world/world.gd
megaproxy 96f4982dd3 Phase 5 cabin polish — door, floor, interior crate, double-render fix
Make the demo cabin readable as a real building so the rendering pattern
is solid before Phase 6+ adds more building types.

Demo seed (world.gd._seed_phase5_demo_buildings):
- 8×6 stone cabin at (44, 23) — 23 walls (perimeter minus door slot) +
  1 door (south wall centre at (47, 28)) + 24 wood-floor designations
  for the interior. ConstructionProvider picks them all up; pawns build
  the whole thing.
- One pre-built crate inside at (50, 24) so the interior reads as a
  furnished room on first frame.
- Two external stockpile-target crates unchanged at (17, 60) / (18, 60).

Door visual rewrite (door.gd):
- Was the old 16×24 bottom-anchored shape that encroached on the cell
  above. Now fits strictly within its 16×16 tile, matching the wall's
  3/4 band layout (5 px lit lintel + 11 px shaded frame + inset panel
  + hinge dot). Door and walls now share a top horizon line so they
  line up visually.

Designation gained TOOL_BUILD_DOOR + atlas mapping; world.gd's
_on_designation_added now branches on build_door to spawn a Door entity.

THE DOUBLE-RENDER BUG (caught by MCP inspection):
- World.mark_floor_tile stamps the Floor TileMap with atlas (2, 0) which
  is *stone-grey* in the placeholder atlas, regardless of material name.
- The Floor TileMap layer was visible=true with z_index=1, so it drew
  ON TOP of the brown Floor entity sprites underneath.
- Result before fix: interior tiles looked gray-stone, not wood.
- Fix: set Floor TileMap layer visible=false (data-only, same as Wall).
  Entities own the visual; the TileMap retains tile-level data for
  Phase 13's room detection + Phase 16's save format.

Pattern locked for future building types: 'render at entity level,
TileMap layers are data-only'. Phase 13's roof and any future wall
materials follow the same template.

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

381 lines
15 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()
# Phase 5: removed _paint_sample_walls() — the old 8×8 stone ring rendered
# only on the (now-hidden) Wall TileMap layer. With the rendering pivot
# to entity-based walls, that ring was invisible to the player but still
# blocked pathing. Removing it cleans the world view; the Phase 5 cabin
# is now the only player-visible structure.
_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]
# Subtle border — was darkened(0.15) which made grass look like graph paper.
# darkened(0.04) keeps the tile boundary readable when squinting but doesn't
# dominate the visual.
var border: Color = base.darkened(0.04)
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 furnished cabin so the construction loop runs end-to-end
# without needing player-paint UI (deferred to Phase 17).
#
# Layout — 8×6 stone cabin at (44, 23), south door, wood floor inside:
# • Perimeter walls (skipping the door slot)
# • Door at (47, 28) — middle of the south wall
# • Wood floor across the 6×4 interior
# • One pre-built crate inside (north-east corner of the interior)
# • Two stockpile-target crates outside (Phase 4 hauling target)
var origin := Vector2i(44, 23)
var w := 8
var h := 6
var door_x := w / 2 - 1 # 3 → tile (47, y_bottom). Centred door.
var door_tile := origin + Vector2i(door_x, h - 1)
# Perimeter walls — skip the door slot in the bottom row.
var wall_count := 0
for x in w:
EventBus.designation_added.emit(origin + Vector2i(x, 0), Designation.TOOL_BUILD_WALL)
wall_count += 1
var bottom_tile := origin + Vector2i(x, h - 1)
if bottom_tile != door_tile:
EventBus.designation_added.emit(bottom_tile, Designation.TOOL_BUILD_WALL)
wall_count += 1
for y in range(1, h - 1): # left + right cols, skip corners (already painted as top/bottom rows)
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)
wall_count += 2
# Door at south wall centre.
EventBus.designation_added.emit(door_tile, Designation.TOOL_BUILD_DOOR)
# Wood floor across the interior — 6×4 = 24 cells.
var floor_count := 0
for x in range(1, w - 1):
for y in range(1, h - 1):
EventBus.designation_added.emit(origin + Vector2i(x, y), Designation.TOOL_BUILD_FLOOR)
floor_count += 1
# Pre-built crate inside the cabin (top-right corner of interior).
# Auto-built so the cabin shows furnished on first frame.
var interior_crate: Crate = CRATE_SCENE.instantiate()
add_child(interior_crate)
interior_crate.setup(origin + Vector2i(w - 2, 1)) # (50, 24)
while interior_crate.is_buildable():
interior_crate.on_build_tick()
# Two external stockpile-target crates south-west (Phase 4 haul destination).
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)
while c.is_buildable():
c.on_build_tick()
Audit.log("world", "phase 5 demo: %d walls + 1 door + %d floors queued; %d crates pre-built" % [
wall_count, floor_count, 1 + 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")
&"build_door":
entity = DOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
_:
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())