rimlike/scenes/world/world.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:55 +01:00

1185 lines
49 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 and rocks. No pre-made stockpiles — items sit where they
## are produced until the player paints storage (a stockpile zone or builds
## a crate). This matches Rimworld parity: storage is a player decision.
##
## 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 BEAUTY_SYSTEM_SCRIPT: Script = preload("res://scenes/world/beauty_system.gd")
const DIRTINESS_SYSTEM_SCRIPT: Script = preload("res://scenes/world/dirtiness_system.gd")
const CLEANING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cleaning_provider.gd")
const INDOOR_TINT_SCRIPT: Script = preload("res://scenes/world/indoor_tint_overlay.gd")
# Phase 14 — grave / burial entities (scripts only; no .tscn needed).
const GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd")
const GRAVE_SLOT_SCRIPT: Script = preload("res://scenes/entities/grave_slot.gd")
# Phase 15 — EventCatalog (class_name in scenes/storyteller/) preloaded so the
# class is resolved before world._ready() fires. Avoids parse-order ambiguity.
const EVENT_CATALOG_SCRIPT: Script = preload("res://scenes/storyteller/event_catalog.gd")
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 BIG_ROCK_SCENE: PackedScene = preload("res://scenes/entities/big_rock.tscn")
const BIG_ROCK_NODE_SCENE: PackedScene = preload("res://scenes/entities/big_rock_node.tscn")
const QUARRY_WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/quarry_workbench.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")
const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn")
const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
const TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn")
# Phase 14 — demo auto-kill toggle.
## Set to true to force-kill the first pawn 50 ticks after boot so MCP runtime
## tests can observe the full death → corpse pipeline at startup.
## LEAVE FALSE for normal play — a colonist dies every session otherwise.
## To enable for a test session: edit this constant, run, revert when done.
const DEMO_PHASE14_AUTOKILL: bool = false
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
const SAMPLE_PAWNS: Array[Dictionary] = [
# Phase 6 demo — varied Crafting skill so the quality system shows visible
# spread. Phase 7 adds Cooking skill for hearth recipe variety.
{"name": "Bram", "tile": Vector2i(20, 40), "crafting": 8, "cooking": 2}, # high crafter, weak cook
{"name": "Cora", "tile": Vector2i(25, 40), "crafting": 4, "cooking": 6}, # mid crafter, strong cook
{"name": "Edda", "tile": Vector2i(30, 40), "crafting": 0, "cooking": 1}, # low crafter, weak cook
]
# Phase 4 — sample harvestables. Trees clustered east, rocks south-east.
# Mix of 8 mature + 4 saplings so players see growth in action from day 1.
const SAMPLE_TREES: Array[Vector2i] = [
Vector2i(58, 30), Vector2i(60, 31), Vector2i(62, 30),
Vector2i(61, 33), Vector2i(63, 34), Vector2i(59, 35),
Vector2i(57, 28), Vector2i(64, 32), # 2 more mature
Vector2i(56, 36), Vector2i(65, 29), # 2 more mature
]
# The first 4 in SAMPLE_TREES_SAPLING are planted as saplings (stage 0).
const SAMPLE_TREES_SAPLING_COUNT: int = 4
const SAMPLE_ROCKS: Array[Vector2i] = [
Vector2i(60, 60), Vector2i(62, 60), Vector2i(63, 62), Vector2i(58, 62),
]
# 2×2 boulder formations (top-left anchor tiles). Two near the small-rock
# cluster so the player sees the size contrast on first scroll.
const SAMPLE_BIG_ROCKS: Array[Vector2i] = [
Vector2i(65, 58), # brown/gray (deterministic per-tile)
Vector2i(56, 64),
]
# Permanent stone outcrops (BigRockNode). Scattered far from the cabin at
# (44, 22)..(51, 28) so the player has to scout / plan transport routes.
# Each is a 2×2 footprint that never depletes; player paints `paint_quarry`
# to build a QuarryWorkbench on it.
const SAMPLE_BIG_ROCK_NODES: Array[Vector2i] = [
Vector2i(12, 30), # west, near map edge
Vector2i(68, 12), # north-east corner area
Vector2i(70, 60), # south-east corner
]
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
# WildGrowth — spontaneous sapling spawning on eligible grass tiles.
# 1200 ticks = 1 in-game hour at 20 Hz (20 ticks/s × 60 s/min = 1200 ticks/min,
# but 1 in-game minute = 20 ticks at 1× so 1 hour = 1200 ticks at 1×).
const WILD_GROWTH_INTERVAL: int = 3000 # ~2.5 in-game hours between attempts
const WILD_GROWTH_SPAWN_PROBABILITY: float = 0.12 # 12% chance per attempt
const MAP_TREE_LIMIT: int = 80
# Rejection-sample attempts before giving up for this tick.
const WILD_GROWTH_MAX_ATTEMPTS: int = 10
# Phase 11 — global darkness tint. Day = white, night = deep cool blue.
# Driven by Clock.darkness_factor() (0..1) each sim tick.
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
const DAY_TINT: Color = Color(1.0, 1.0, 1.0, 1.0)
# Phase 12 — subtle seasonal modulate on the Terrain TileMapLayer only.
# Wall / Floor / Roof are entity-drawn (Y-sorted sprites); don't tint those.
const SEASON_TINTS: Dictionary = {
&"spring": Color(0.96, 1.02, 0.94), # slight warm-green
&"summer": Color(1.0, 1.0, 1.0 ), # neutral / bright
&"autumn": Color(1.06, 0.95, 0.82), # warm orange
&"winter": Color(0.88, 0.92, 1.02), # cool blue, slight desaturation
}
@onready var dark_overlay: CanvasModulate = $DarkOverlay
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var decoration_layer: TileMapLayer = $Decoration
@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
@onready var crafting_provider: CraftingProvider = $CraftingProvider
@onready var plant_provider: PlantProvider = $PlantProvider
@onready var eat_provider: EatProvider = $EatProvider
@onready var sleep_provider: SleepProvider = $SleepProvider
@onready var doctor_provider: DoctorProvider = $DoctorProvider
@onready var room_detector = $RoomDetector # RoomDetector — duck-typed (class_name scan-time window)
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()
# Decoration overlay — sparse grass tufts + flowers from the ElvGames bundle.
# Lives on its own TileMapLayer above Terrain so a future season swap is one
# tileset reassignment.
decoration_layer.tile_set = _build_decoration_tileset()
_paint_decorations()
# 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)
# Bind the camera so Tab-cycle and center-on-selection can pan to pawns.
var camera_rig := get_node_or_null("CameraRig")
if camera_rig != null:
selection.bind_camera(camera_rig)
else:
Audit.log("world", "selection.bind_camera: CameraRig not found")
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.terrain_layer = terrain_layer
World.wall_layer = wall_layer
World.floor_layer = floor_layer
World.designation_layer = designation_layer
World.roof_layer = roof_layer
# Phase 13 — wire RoomDetector; setup with map size so BFS knows map bounds.
room_detector.setup(MAP_SIZE_TILES)
World.room_detector = room_detector
# Phase 13 — instantiate BeautySystem + DirtinessSystem + CleaningProvider as
# runtime children (no .tscn entry needed; they're stateful Nodes with no
# editor-tunable exports). Autoload refs let entity code reach them.
var beauty := Node.new()
beauty.set_script(BEAUTY_SYSTEM_SCRIPT)
beauty.name = "BeautySystem"
add_child(beauty)
World.beauty_system = beauty
var dirtiness := Node.new()
dirtiness.set_script(DIRTINESS_SYSTEM_SCRIPT)
dirtiness.name = "DirtinessSystem"
add_child(dirtiness)
World.dirtiness_system = dirtiness
var cleaning := Node.new()
cleaning.set_script(CLEANING_PROVIDER_SCRIPT)
cleaning.name = "CleaningProvider"
add_child(cleaning)
# Phase 13 — instantiate IndoorTintOverlay so roofed rooms get a subtle warm
# overlay. Node2D, z_index 3 (set in its _ready). Self-listens to room_changed.
var indoor_tint := Node2D.new()
indoor_tint.set_script(INDOOR_TINT_SCRIPT)
indoor_tint.name = "IndoorTintOverlay"
add_child(indoor_tint)
# Designation: bind the paint surface + the Selection guard.
designation_ctl.bind(designation_layer, selection)
# Expose on the autoload so entities can clear their ghost stamp on
# completion (Tree.fell, Wall._complete, etc.).
World.designation_ctl = designation_ctl
# Register all 10 providers — Decision iterates by .priority desc.
# doctor=9 > sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4
# ≈ crafting=4 > haul=3 > rest=0.
# Phase 17 will tune these via the work-priority matrix UI.
World.register_work_provider(doctor_provider)
World.register_work_provider(sleep_provider)
World.register_work_provider(eat_provider)
World.register_work_provider(construction_provider)
World.register_work_provider(chop_provider)
World.register_work_provider(mine_provider)
World.register_work_provider(crafting_provider)
World.register_work_provider(plant_provider)
World.register_work_provider(hauling_provider)
World.register_work_provider(cleaning) # priority 2 — between haul (3) and rest (0)
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()
# No pre-made stockpiles: the player must paint their own storage. Items
# from chop/mine/crafting sit where they're produced until a player-made
# stockpile or crate exists for hauling. (2026-05-15 — _spawn_sample_stockpiles
# removed; was leftover Phase 4 acceptance scaffolding south-west of the cabin.)
_seed_phase5_demo_buildings()
# Phase 13 — pre-stamp the cabin walls + floors on the TileMap data layers
# so RoomDetector can see a completed enclosure at boot without waiting for
# pawns to finish the build queue. Mirrors the layout in _seed_phase5_demo_buildings.
_prestamp_cabin_for_room_detector()
_prestamp_test_shed_for_room_detector()
room_detector.recompute_around(Vector2i(47, 25))
room_detector.recompute_around(Vector2i(36, 25)) # tiny shed centroid
# Phase 13 — register existing prebuilt furniture with BeautySystem so it has
# a beauty score baseline at boot (the post-_complete hooks already fire for
# the cabin's beds/torches/workbenches, but pawns may path before the first
# recompute, so seed the map here).
for ws in World.workbenches:
beauty.register_furniture(ws)
for b in World.beds:
beauty.register_furniture(b)
for ls in World.light_sources:
beauty.register_furniture(ls)
beauty.recompute_all()
_run_pathfinder_spike()
# Phase 15 — register all 25 Storyteller events now that World state and
# autoloads are fully mounted (trigger predicates read Clock, World, Storyteller).
EVENT_CATALOG_SCRIPT.register_all(Storyteller)
# Phase 17 — wanderer events spawn new pawns via EventBus so EventCatalog
# stays static (no get_node() into the scene tree).
EventBus.request_pawn_spawn.connect(_on_request_pawn_spawn)
# 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)
# Phase 12 — season tint: apply on boot then update on each season transition.
_apply_season_tint(Clock.current_season())
EventBus.season_changed.connect(_apply_season_tint)
# Phase 14 — demo auto-kill (disabled by default — see DEMO_PHASE14_AUTOKILL).
if DEMO_PHASE14_AUTOKILL:
EventBus.sim_tick.connect(_demo_phase14_autokill_hook)
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)
# ── decoration overlay (ElvGames Grasslands sprinkle) ────────────────────────
## Source id for the decoration tileset (separate atlas from PLACEHOLDER_SOURCE_ID).
const DECORATION_SOURCE_ID: int = 10
## Atlas-tile coords inside FG_Grasslands_Spring.png for overlay variants.
## Each is a 16×16 sprite on transparent background, picked by visual eyeball
## (see /tmp/deco_review.png in the 2026-05-12 visual-pass session). Mix of
## small-grass sprouts, flowers, and scattered-tuft sprites — all sit cleanly
## on top of the base grass tile without occluding pawns.
const DECORATION_TILES: Array[Vector2i] = [
Vector2i(12, 0), # grass sprout A
Vector2i(13, 0), # grass sprout B
Vector2i(14, 0), # scattered grass dots — small
Vector2i(16, 0), # white flower / red center
Vector2i(17, 0), # white flower / yellow center
Vector2i(18, 0), # white flower / purple center
Vector2i(16, 2), # scattered grass dots — medium
Vector2i(17, 2), # scattered grass dots — large
]
## How often a tile gets a decoration. 0.08 = ~8% density — visually rich but
## not so dense that pawns/items get hidden under it. Tunable; tested at
## ~500 decorations on the 80×80 map.
const DECORATION_DENSITY: float = 0.08
func _build_decoration_tileset() -> TileSet:
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
var tex: Texture2D = load("res://art/tiles/FG_Grasslands_Spring.png")
if tex == null:
Audit.log("world", "decoration: FG_Grasslands_Spring.png not loadable; skipping overlay")
return ts
var src := TileSetAtlasSource.new()
src.texture = tex
src.texture_region_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
for coord in DECORATION_TILES:
src.create_tile(coord)
ts.add_source(src, DECORATION_SOURCE_ID)
return ts
func _paint_decorations() -> void:
# Deterministic-ish seed so the layout is stable across runs (cosmetic only;
# not save-round-tripped). Mix world size into seed for "feels random but
# reproducible." Use a fresh RNG so we don't perturb the global state.
var rng := RandomNumberGenerator.new()
rng.seed = MAP_SIZE_TILES.x * 31 + MAP_SIZE_TILES.y * 17
var placed: int = 0
for x in MAP_SIZE_TILES.x:
for y in MAP_SIZE_TILES.y:
if rng.randf() >= DECORATION_DENSITY:
continue
var coord: Vector2i = DECORATION_TILES[rng.randi() % DECORATION_TILES.size()]
decoration_layer.set_cell(Vector2i(x, y), DECORATION_SOURCE_ID, coord)
placed += 1
Audit.log("world", "decoration: painted %d overlay cells" % placed)
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
# Phase 6: seed Crafting skill so the quality demo shows variety.
if spawn_data.has("crafting"):
p.set_skill(Pawn.SKILL_CRAFTING, int(spawn_data["crafting"]))
# Phase 7: seed Cooking skill so hearth recipes show quality spread too.
if spawn_data.has("cooking"):
p.set_skill(Pawn.SKILL_COOKING, int(spawn_data["cooking"]))
World.register_pawn(p)
## Phase 17 — Wanderer event handler: instantiate a new pawn at the map entry
## point (top-left area) with optional seeded skills from `skills` dict.
## Called via EventBus.request_pawn_spawn emitted by EventCatalog helpers.
## Skills dict supports the same keys as Pawn.SKILL_* constants:
## "crafting", "cooking", "medicine", "combat", "manual_labor"
const WANDERER_NAMES: Array[String] = [
"Aldric", "Maren", "Sven", "Tilda", "Piers", "Wren", "Gareth", "Isolde",
"Finn", "Runa", "Oswin", "Elke", "Bertram", "Sigrid", "Leofric", "Astrid",
]
const WANDERER_SPAWN_TILE: Vector2i = Vector2i(5, 5) # map entry corner
func _on_request_pawn_spawn(skills: Dictionary) -> void:
# Pick a name not already in use.
var used_names: Array = []
for p in World.pawns:
used_names.append(p.pawn_name)
var available: Array[String] = []
for n in WANDERER_NAMES:
if not used_names.has(n):
available.append(n)
var chosen_name: String = available[randi() % available.size()] if not available.is_empty() else "Wanderer"
var p: Pawn = PAWN_SCENE.instantiate()
add_child(p)
p.setup(chosen_name, WANDERER_SPAWN_TILE)
var jr := JobRunner.new()
jr.name = "JobRunner"
p.add_child(jr)
jr.setup(p, pathfinder)
p.job_runner = jr
# Seed any skills specified by the event (e.g. combat: 8 for old soldier).
for skill_key in skills:
var sn: StringName = StringName(skill_key)
if sn in Pawn.ALL_SKILLS:
p.set_skill(sn, int(skills[skill_key]))
World.register_pawn(p)
Audit.log("world", "wanderer spawned: '%s' at %s skills=%s" % [chosen_name, WANDERER_SPAWN_TILE, str(skills)])
# ── Phase 4: harvestables (stockpile-zone seeding removed 2026-05-15) ───────
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.
# Boot seed auto-designates so the production-chain demo runs end-to-end
# without requiring a player to paint chop/mine first. Real player-painted
# trees / rocks still gate on chop_designated / mine_designated (Rimworld parity).
for i in SAMPLE_TREES.size():
var tree = TREE_SCENE.instantiate()
add_child(tree)
# First SAMPLE_TREES_SAPLING_COUNT trees spawn as saplings (stage 0)
# so the player can observe growth from day 1. The rest are mature.
var stage: int = HarvestableTree.STAGE_SAPLING if i < SAMPLE_TREES_SAPLING_COUNT else HarvestableTree.STAGE_MATURE
tree.setup(SAMPLE_TREES[i], stage)
if stage == HarvestableTree.STAGE_MATURE:
tree.chop_designated = true
for r_tile in SAMPLE_ROCKS:
var rock = ROCK_SCENE.instantiate()
add_child(rock)
rock.setup(r_tile)
rock.mine_designated = true
for br_origin in SAMPLE_BIG_ROCKS:
var big = BIG_ROCK_SCENE.instantiate()
add_child(big)
big.setup(br_origin)
big.mine_designated = true
# Permanent stone outcrops (never deplete; Quarry workbench built on them).
for node_origin in SAMPLE_BIG_ROCK_NODES:
var node = BIG_ROCK_NODE_SCENE.instantiate()
add_child(node)
node.setup(node_origin)
Audit.log("world", "spawned %d trees + %d rocks + %d big rocks + %d stone outcrops" % [
SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size(), SAMPLE_BIG_ROCK_NODES.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×7 stone cabin at (44, 22), 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×5 interior (rows 23..27)
# • One pre-built crate inside (north-east corner of the interior — the
# cabin's starting amenity; player paints additional storage later)
# Bumped from 8×6 → 8×7 so the north interior row (23) is free for the
# bed sprites to extend into (beds are 1×2, anchored at the foot, head
# extends one tile up). Previously the headboards clipped into the wall.
var origin := Vector2i(44, 22)
var w := 8
var h := 7
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, 23) — top-right of interior
while interior_crate.is_buildable():
interior_crate.on_build_tick()
# (2026-05-15) Two external SW crates removed alongside _spawn_sample_stockpiles:
# they were Phase 4 haul-destination scaffolding, no longer needed now that
# the player paints their own storage. The interior cabin crate above stays
# as a starting amenity.
Audit.log("world", "phase 5 demo: %d walls + 1 door + %d floors queued; 1 interior crate pre-built" % [
wall_count, floor_count
])
# Phase 6 demo — two pre-built workbenches inside the cabin with bills.
# Carpenter at (46, 25) makes wood → plank, FOREVER.
# Smelter at (48, 25) makes stone → stone_block, UNTIL 5 in stockpiles.
var carpenter: Workbench = WORKBENCH_SCENE.instantiate()
add_child(carpenter)
carpenter.setup(Vector2i(46, 25))
carpenter.label_text = "Carpenter"
carpenter.accepted_skill = Pawn.SKILL_CRAFTING
while carpenter.is_buildable():
carpenter.on_build_tick()
var plank_bill := Bill.new()
plank_bill.recipe = RecipeCatalog.plank()
plank_bill.mode = Bill.Mode.FOREVER
carpenter.add_bill(plank_bill)
var smelter: Workbench = WORKBENCH_SCENE.instantiate()
add_child(smelter)
smelter.setup(Vector2i(48, 25))
smelter.label_text = "Smelter"
smelter.accepted_skill = Pawn.SKILL_CRAFTING
while smelter.is_buildable():
smelter.on_build_tick()
var block_bill := Bill.new()
block_bill.recipe = RecipeCatalog.stone_block()
block_bill.mode = Bill.Mode.UNTIL_N
block_bill.target_count = 5
smelter.add_bill(block_bill)
Audit.log("world", "phase 6 demo: 2 workbenches built (Carpenter→planks FOREVER, Smelter→blocks UNTIL_N=5)")
# Phase 7 demo — Millstone + Hearth inside the cabin (south row), so the
# grain→flour→bread chain runs visibly. Wheat crops outside the cabin to
# the east. A couple of pre-baked breads + a meal item near the cabin so
# the eat-loop is testable even before cooking completes.
var millstone: Workbench = WORKBENCH_SCENE.instantiate()
add_child(millstone)
millstone.setup(Vector2i(46, 27))
millstone.label_text = "Millstone"
millstone.accepted_skill = Pawn.SKILL_CRAFTING
while millstone.is_buildable():
millstone.on_build_tick()
var flour_bill := Bill.new()
flour_bill.recipe = RecipeCatalog.flour()
flour_bill.mode = Bill.Mode.FOREVER
millstone.add_bill(flour_bill)
var hearth: Workbench = WORKBENCH_SCENE.instantiate()
add_child(hearth)
hearth.setup(Vector2i(49, 27))
hearth.label_text = "Hearth"
hearth.accepted_skill = Pawn.SKILL_COOKING
while hearth.is_buildable():
hearth.on_build_tick()
var bread_bill := Bill.new()
bread_bill.recipe = RecipeCatalog.bread()
bread_bill.mode = Bill.Mode.FOREVER
hearth.add_bill(bread_bill)
var meal_bill := Bill.new()
meal_bill.recipe = RecipeCatalog.meal_from_vegetables()
meal_bill.mode = Bill.Mode.FOREVER
hearth.add_bill(meal_bill)
# Mixed crops east of the cabin, near the trees. One column per kind so the
# player sees the four atlas variants side-by-side from the boot demo.
var crop_plan: Array = [
[Vector2i(54, 24), Crop.KIND_WHEAT],
[Vector2i(54, 25), Crop.KIND_WHEAT],
[Vector2i(54, 26), Crop.KIND_WHEAT],
[Vector2i(55, 24), Crop.KIND_POTATO],
[Vector2i(55, 25), Crop.KIND_POTATO],
[Vector2i(55, 26), Crop.KIND_POTATO],
[Vector2i(56, 24), Crop.KIND_CORN],
[Vector2i(56, 25), Crop.KIND_CORN],
[Vector2i(56, 26), Crop.KIND_CORN],
[Vector2i(57, 24), Crop.KIND_STRAWBERRY],
[Vector2i(57, 25), Crop.KIND_STRAWBERRY],
[Vector2i(57, 26), Crop.KIND_STRAWBERRY],
]
var crop_tiles: Array[Vector2i] = []
for entry in crop_plan:
var ct: Vector2i = entry[0]
var kind: StringName = entry[1]
crop_tiles.append(ct)
var c: Crop = CROP_SCENE.instantiate()
add_child(c)
c.setup(ct, kind, Crop.Stage.SOWN)
# Pre-baked breads + a vegetable meal so pawns can eat before the
# full cooking chain finishes. Phase 17 may remove these as cooking
# becomes reliable; for Phase 7 demo they keep the loop unblocked.
var snack_tiles: Array[Vector2i] = [Vector2i(45, 21), Vector2i(50, 21)]
for st in snack_tiles:
var bread_item: Item = ITEM_SCENE.instantiate()
add_child(bread_item)
bread_item.setup(Item.TYPE_BREAD, 1, st)
bread_item.quality = Item.Quality.NORMAL
Audit.log("world", "phase 7 demo: Millstone+Hearth built, %d crops sown (mixed kinds), %d pre-baked breads placed" % [
crop_tiles.size(), snack_tiles.size()
])
# Phase 8 demo — 3 beds inside the cabin's north row. Beds are pre-built
# (skip construction) so pawns have somewhere to sleep on first tired tick.
# (45, 24), (47, 24), (49, 24) — leaves (50, 24) for the existing crate.
# 3 beds in cabin north row. Phase 9 — middle bed is the medical bed so
# the doctor flow has a treatment target.
var bed_tiles: Array[Vector2i] = [Vector2i(45, 24), Vector2i(47, 24), Vector2i(49, 24)]
for i in bed_tiles.size():
var bt: Vector2i = bed_tiles[i]
var bed: Bed = BED_SCENE.instantiate()
add_child(bed)
bed.setup(bt)
if i == 1: # middle bed = medical
bed.is_medical = true
while bed.is_buildable():
bed.on_build_tick()
Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size())
# Phase 11 demo — 2 torches inside cabin (north-east + south-west corners
# of interior) so the indoor area stays lit at night. Combined with the
# Hearth's light radius=5, the cabin interior should be mostly bright
# while the outdoors goes deep-blue tinted.
var torch_tiles: Array[Vector2i] = [Vector2i(46, 26), Vector2i(49, 26)]
for tt in torch_tiles:
var torch: Torch = TORCH_SCENE.instantiate()
add_child(torch)
torch.setup(tt)
while torch.is_buildable():
torch.on_build_tick()
Audit.log("world", "phase 11 demo: %d torches pre-built inside cabin" % torch_tiles.size())
# ── 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:
# Replacement rule: painting a door on a tile occupied by a Wall (ghost or
# completed) demolishes the wall in place so the door build can proceed.
# Mirrors the Going-Medieval / Rimworld "door replaces wall" convention.
# Source of truth is World.build_queue — pre-built walls (test shed, cabin
# seed) aren't in _build_sites_by_tile but ARE in build_queue via _ready.
if tool == &"build_door":
var wall_at_cell = _find_wall_at(cell)
if wall_at_cell != null:
_demolish_wall_in_place(wall_at_cell, cell)
_build_sites_by_tile.erase(cell) # may not be present; erase is no-op
# Fall through to spawn the door ghost below.
if _build_sites_by_tile.has(cell):
return # already a build site here
# Phase 17 — read material override from the Designation controller (may be "").
var mat: StringName = designation_ctl.tool_material if designation_ctl != null else &""
var entity = null
match tool:
&"build_wall":
var wall_mat: StringName = mat if mat != &"" else &"stone"
entity = WALL_SCENE.instantiate()
add_child(entity)
entity.setup(cell, wall_mat)
&"build_floor":
var floor_mat: StringName = mat if mat != &"" else &"wood"
entity = FLOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell, floor_mat)
&"build_door":
entity = DOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
# Phase 14 — graveyard zone: paint a single-cell GraveyardZone.
&"graveyard":
var gz := Node2D.new()
gz.set_script(GRAVEYARD_ZONE_SCRIPT)
gz.name = "GraveyardZone_%d_%d" % [cell.x, cell.y]
add_child(gz)
gz.region = Rect2i(cell.x, cell.y, 1, 1)
gz.label = "Graveyard"
gz.queue_redraw()
entity = gz
# Phase 14 — dig grave: spawn a GraveSlot ghost and queue it for build.
&"dig_grave":
var gs := Node2D.new()
gs.set_script(GRAVE_SLOT_SCRIPT)
gs.name = "GraveSlot_%d_%d" % [cell.x, cell.y]
add_child(gs)
gs.setup(cell)
entity = gs
# Chop / mine: flag the Tree / Rock entity at this tile so ChopProvider /
# MineProvider treat it as work. Designation ghost on the TileMap is the
# visual cue; _build_sites_by_tile holds a sentinel so cancel can clear.
&"chop":
for t in World.trees:
if t.tile == cell:
t.chop_designated = true
break
_build_sites_by_tile[cell] = null
Audit.log("world", "chop designation at %s" % cell)
return
&"mine":
for r in World.rocks:
# BigRock occupies a 2×2 footprint; flag if cell is any of them.
if r.has_method("footprint_tiles"):
if cell in r.footprint_tiles():
r.mine_designated = true
break
elif r.tile == cell:
r.mine_designated = true
break
_build_sites_by_tile[cell] = null
Audit.log("world", "mine designation at %s" % cell)
return
# Phase 17 — crate.
&"build_crate":
entity = CRATE_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
# Phase 17 — bed.
&"build_bed":
entity = BED_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
# Phase 17 — torch.
&"build_torch":
entity = TORCH_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
# Phase 17 — workbench variants.
&"build_workbench_carpenter":
entity = _spawn_workbench(cell, "Carpenter", Pawn.SKILL_CRAFTING)
&"build_workbench_smelter":
entity = _spawn_workbench(cell, "Smelter", Pawn.SKILL_CRAFTING)
&"build_workbench_millstone":
entity = _spawn_workbench(cell, "Millstone", Pawn.SKILL_CRAFTING)
&"build_workbench_hearth":
entity = _spawn_workbench(cell, "Hearth", Pawn.SKILL_COOKING)
&"build_workbench_cremation_pyre":
entity = _spawn_workbench(cell, "Cremation Pyre", Pawn.SKILL_CRAFTING)
# Phase 17 — general stockpile paint: 1-cell stockpile zone, accepts all.
&"paint_stockpile":
var sz: StockpileZone = STOCKPILE_SCENE.instantiate()
add_child(sz)
sz.region = Rect2i(cell.x, cell.y, 1, 1)
sz.label = "Stockpile"
sz.priority = StorageDestination.Priority.NORMAL
sz.accepted_types = [] as Array[StringName] # wildcard
sz.queue_redraw()
entity = sz
# Quarry — must be placed on a BigRockNode tile. Spawns a
# QuarryWorkbench ghost (auto-FOREVER bill on completion).
&"paint_quarry":
var node = World.big_rock_node_at_tile(cell)
if node == null:
Audit.log("world", "paint_quarry: %s not on a stone outcrop — rejected" % cell)
return
# Refuse if this node already has a quarry built/queued.
for ws in World.workbenches:
if "label_text" in ws and ws.label_text == "Quarry" and node.is_at(ws.tile):
Audit.log("world", "paint_quarry: outcrop at %s already has a quarry" % cell)
return
var quarry = QUARRY_WORKBENCH_SCENE.instantiate()
add_child(quarry)
quarry.setup(cell)
entity = quarry
# Tree planting — spawn a ghost sapling with pending_plant=true so
# ConstructionProvider can queue a build job (1 wood, 30 ticks of work).
# The ghost renders as a translucent sprout until the pawn completes it.
&"plant_tree":
# Reject if tile is already occupied by a tree.
for existing_t in World.trees:
if is_instance_valid(existing_t) and existing_t.tile == cell:
Audit.log("world", "plant_tree: tile %s already has a tree — skipped" % cell)
return
var pt = TREE_SCENE.instantiate()
add_child(pt)
pt.setup(cell, HarvestableTree.STAGE_SAPLING)
pt.pending_plant = true
# Register as a build site so ConstructionProvider can assign a pawn.
World.register_build_site(pt)
entity = pt
_:
Audit.log("world", "unknown designation tool: %s" % tool)
return
_build_sites_by_tile[cell] = entity
Audit.log("world", "queued %s at %s" % [tool, cell])
## True if `entity` is a Wall (ghost or completed). Duck-typed by script path
## to avoid registration-order issues with class_name Wall.
func _entity_is_wall(entity) -> bool:
if entity == null:
return false
var s = entity.get_script()
return s != null and s.resource_path.ends_with("/wall.gd")
## Finds the Wall entity at `cell` (ghost or completed), or null. Searches
## World.build_queue first since every Wall self-registers there on _ready;
## that covers both designation-painted walls and pre-built _spawn_complete_wall
## seeds.
func _find_wall_at(cell: Vector2i):
for site in World.build_queue:
if not is_instance_valid(site):
continue
if site.get("tile") != cell:
continue
if _entity_is_wall(site):
return site
return null
## Atomically remove a Wall entity at `cell`. Reverses Wall._complete() effects
## if the wall was already built: unstamps the wall_layer TileMap, marks the
## pathfinder cell walkable, and triggers a room recompute. Then frees the
## entity. Used by the door-replaces-wall replacement rule.
func _demolish_wall_in_place(wall, cell: Vector2i) -> void:
var was_completed: bool = wall.has_method("is_completed") and wall.is_completed()
if was_completed:
wall_layer.erase_cell(cell)
pathfinder.set_cell_walkable(cell, true)
if room_detector != null:
room_detector.recompute_around(cell)
wall.queue_free()
Audit.log("world", "wall at %s demolished (replaced by door designation)" % cell)
## Instantiate a Workbench ghost (in build-queue state) at `tile` with the
## given label and accepted skill. Returns the entity (already add_child'd).
func _spawn_workbench(tile: Vector2i, label: String, skill: StringName):
var wb: Workbench = WORKBENCH_SCENE.instantiate()
add_child(wb)
wb.setup(tile)
wb.label_text = label
wb.accepted_skill = skill
return wb
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)
# Phase 17 — chop/mine designations store null as their sentinel; nothing to
# free, but clear the corresponding flag on any Tree / Rock at that tile.
if entity == null:
for t in World.trees:
if t.tile == cell:
t.chop_designated = false
break
for r in World.rocks:
if r.has_method("footprint_tiles"):
if cell in r.footprint_tiles():
r.mine_designated = false
break
elif r.tile == cell:
r.mine_designated = false
break
return
if not is_instance_valid(entity):
return
# For build-queue entities (Wall, Floor, Door, GraveSlot): only free if not
# yet completed (a completed wall should not be torn down by a cancel signal).
# For zone-overlay entities (GraveyardZone) that have no is_completed(): always free.
var can_complete: bool = entity.has_method("is_completed")
if not can_complete or 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:
_update_dark_overlay()
# Tree growth — tick every registered tree; mature + pending-plant trees are
# no-ops inside on_sim_tick(), so iterating all is safe.
for tree in World.trees:
if is_instance_valid(tree):
tree.on_sim_tick()
# WildGrowth — attempt to plant a new sapling once per WILD_GROWTH_INTERVAL.
if tick_n % WILD_GROWTH_INTERVAL == 0:
_try_wild_growth()
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
## Attempt to spawn one wild sapling on a random eligible grass tile.
## Eligibility: walkable + grass terrain + no entity overlap + < 2 tree neighbours.
## Gives up after WILD_GROWTH_MAX_ATTEMPTS rejected tries to avoid lag spikes.
func _try_wild_growth() -> void:
if World.trees.size() >= MAP_TREE_LIMIT:
return
if randf() >= WILD_GROWTH_SPAWN_PROBABILITY:
return
var rng := RandomNumberGenerator.new()
rng.seed = Sim.tick + 9973 # stable within a tick, different each call cycle
for _attempt in WILD_GROWTH_MAX_ATTEMPTS:
var candidate := Vector2i(
rng.randi_range(0, MAP_SIZE_TILES.x - 1),
rng.randi_range(0, MAP_SIZE_TILES.y - 1)
)
if not _wild_growth_tile_eligible(candidate):
continue
var tree = TREE_SCENE.instantiate()
add_child(tree)
tree.setup(candidate, HarvestableTree.STAGE_SAPLING)
Audit.log("world", "wild growth: sapling at %s (total %d)" % [candidate, World.trees.size()])
return
## Returns true if `tile` is a valid WildGrowth spawn location.
## Checks: walkable, grass terrain (source 0, atlas (0,0)), no entity at tile,
## and fewer than 2 trees among the 4 cardinal neighbours.
func _wild_growth_tile_eligible(tile: Vector2i) -> bool:
# Bounds check (pathfinder bounds == map bounds).
if pathfinder == null:
return false
if not pathfinder.is_walkable(tile):
return false
# Grass terrain only — atlas (0,0) on source 0 = TILE_GRASS.
if terrain_layer == null:
return false
var src_id: int = terrain_layer.get_cell_source_id(tile)
var atlas: Vector2i = terrain_layer.get_cell_atlas_coords(tile)
if src_id != PLACEHOLDER_SOURCE_ID or atlas != TILE_GRASS:
return false
# No existing tree, rock, or item at this tile.
for t in World.trees:
if is_instance_valid(t) and t.tile == tile:
return false
for r in World.rocks:
if is_instance_valid(r):
if r.has_method("footprint_tiles"):
if tile in r.footprint_tiles():
return false
elif r.tile == tile:
return false
for it in World.items:
if is_instance_valid(it) and it.tile == tile:
return false
# No clumping: reject if 2+ cardinal neighbours already have a tree.
var neighbour_trees: int = 0
var offsets: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
for offset in offsets:
var nb: Vector2i = tile + offset
for t in World.trees:
if is_instance_valid(t) and t.tile == nb:
neighbour_trees += 1
break
return neighbour_trees < 2
# Phase 11 — interpolate CanvasModulate between DAY_TINT and NIGHT_TINT based
# on Clock.darkness_factor() (0 = full day, 1 = full night).
# Called every sim tick; Color.lerp is a handful of float ops — negligible cost.
func _update_dark_overlay() -> void:
var f := Clock.darkness_factor()
dark_overlay.color = DAY_TINT.lerp(NIGHT_TINT, f)
# Phase 12 — apply a subtle hue tint to the Terrain layer to signal season.
# Only Terrain is tinted; Wall/Floor/Roof are entity sprites (Y-sorted) — leave them neutral.
# Called once at boot (from _ready) and on every EventBus.season_changed signal.
func _apply_season_tint(season: StringName) -> void:
var tint: Color = SEASON_TINTS.get(season, Color.WHITE)
terrain_layer.modulate = tint
Audit.log("world", "season tint → %s (%s)" % [season, tint])
# ── Phase 13: demo seed room helper ─────────────────────────────────────────
# Directly stamps the cabin perimeter walls and interior floors on the data
# TileMap layers so RoomDetector can detect the enclosure at boot.
# The ghost entities are already queued — this only writes the data layer
# (same as Wall._complete / Floor._complete call). Without this,
# RoomDetector would have to wait until pawns finish building the walls
# (many seconds of real time) before the first room is visible.
#
# Layout mirrors _seed_phase5_demo_buildings: 8×6 cabin at (44, 23),
# door at (47, 28), 6×4 wood floor interior.
func _prestamp_cabin_for_room_detector() -> void:
# Matches _seed_phase5_demo_buildings: 8×7 cabin at (44, 22), 6×5 interior.
var origin := Vector2i(44, 22)
var w := 8
var h := 7
var door_x := w / 2 - 1 # 3 → tile x=47
var door_tile := origin + Vector2i(door_x, h - 1)
# Stamp perimeter walls (skipping the door slot).
for x in w:
World.mark_wall_tile(origin + Vector2i(x, 0), &"stone")
var bottom := origin + Vector2i(x, h - 1)
if bottom != door_tile:
World.mark_wall_tile(bottom, &"stone")
for y in range(1, h - 1):
World.mark_wall_tile(origin + Vector2i(0, y), &"stone")
World.mark_wall_tile(origin + Vector2i(w - 1, y), &"stone")
# Stamp interior floors.
for x in range(1, w - 1):
for y in range(1, h - 1):
World.mark_floor_tile(origin + Vector2i(x, y), &"wood")
# Stamp the door slot as a wall so the perimeter is fully closed and BFS
# terminates cleanly. Door._complete() erases this wall stamp and registers
# the door entity; the follow-up recompute_around picks it up as a boundary.
World.mark_wall_tile(door_tile, &"stone")
Audit.log("world", "phase 13 demo: cabin walls+floors pre-stamped for RoomDetector")
# Tiny 5×5 walled shed with 3×3 interior (= 9 floor tiles) — under the 16-cap
# auto-roof threshold, so this room WILL roof. Used to exercise the indoor /
# shelter / room-thoughts pipeline without resizing the main cabin.
func _prestamp_test_shed_for_room_detector() -> void:
var origin := Vector2i(34, 23) # left of cabin, on the grass plain
var w := 5
var h := 5
# Perimeter walls — instantiate complete Wall entities so they're visible.
for x in w:
_spawn_complete_wall(origin + Vector2i(x, 0))
_spawn_complete_wall(origin + Vector2i(x, h - 1))
for y in range(1, h - 1):
_spawn_complete_wall(origin + Vector2i(0, y))
_spawn_complete_wall(origin + Vector2i(w - 1, y))
# Interior floors — instantiate complete Floor entities.
for x in range(1, w - 1):
for y in range(1, h - 1):
_spawn_complete_floor(origin + Vector2i(x, y))
Audit.log("world", "phase 13 demo: 5×5 test shed pre-built (interior 9 tiles, auto-roofed)")
# Instantiate a Wall entity in completed state at `tile`. Bypasses the build
# queue. Used by the Phase 13 test shed seed.
func _spawn_complete_wall(tile: Vector2i) -> void:
var w = WALL_SCENE.instantiate()
w.setup(tile, &"stone")
add_child(w)
w.build_progress = w.BUILD_TICKS
w.on_build_tick() # triggers _complete() and the data-layer stamp + room recompute
# Instantiate a Floor entity in completed state at `tile`.
func _spawn_complete_floor(tile: Vector2i) -> void:
var f = FLOOR_SCENE.instantiate()
f.setup(tile, &"wood")
add_child(f)
f.build_progress = f.BUILD_TICKS
f.on_build_tick()
# ── 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"]
])
# ── Phase 14: demo auto-kill hook ───────────────────────────────────────────
## Connected to EventBus.sim_tick when DEMO_PHASE14_AUTOKILL is true.
## Force-kills the first pawn 50 ticks after boot so the death→corpse pipeline
## can be tested in a normal play session. LEAVE DEMO_PHASE14_AUTOKILL = false
## for normal play.
func _demo_phase14_autokill_hook(tick_n: int) -> void:
if tick_n != 50:
return
if World.pawns.is_empty():
return
var victim = World.pawns[0]
if victim.has_method("take_damage"):
Audit.log("world", "phase 14 demo: force-killing '%s' at tick %d" % [victim.pawn_name, tick_n])
victim.take_damage(victim.hp, &"demo_kill")
EventBus.sim_tick.disconnect(_demo_phase14_autokill_hook)
# ── 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())