rimlike/scenes/world/world.gd
megaproxy 1b6ad2bcc6 Phase 9+10: Status/Doctor/Medical bed + Wolves/WolfSpawner
The 'drama pair' shipped together via 3-agent fan-out.

Phase 9 — Status effects + Medicine:
- Status data class (PERSISTENT/EVENT, severity stacks max=3) + StatusCatalog
  (Bleeding ticks HP loss; Downed = incapacitated)
- Pawn HP (100 max, 30 downed threshold, 50 revive threshold), take_damage,
  heal, add_status/remove_status_by_id, is_downed/is_incapacitated, downed
  visual (body rotated 90° + desaturated)
- DoctorProvider (priority 9, highest) — scans World.pawns for nearest downed
  pawn, finds medical bed (or any bed fallback), emits 4-toil job:
  walk_to_patient → rescue → walk_to_bed → treat
- Bed.is_medical with red-cross marker draw on pillow; round-trips save
- KIND_RESCUE + KIND_TREAT toils + JobRunner _tick_rescue/_tick_treat
  (snap-to-bed on first treat tick, +0.5 hp/tick, bleed cure at 100-tick
  intervals; done at HP≥50 + no bleeding, 600-tick timeout)
- EventBus: pawn_took_damage, pawn_status_added, pawn_status_removed

Phase 10 — Combat + Wolves (wolf-first slice):
- Wolf entity (Node2D, 4-state APPROACH/ENGAGE/FLEE/DEAD, procedural
  canine sprite with red glowing eyes, 40 HP)
- Two-roll combat: 70% hit + 50% chance to apply Bleeding(1) on hit
- WolfSpawner — triggers at Clock.darkness_factor()≥0.8 with 1-in-game-day
  cooldown, packs of 1–2 at random map-edge cluster
- World.wolves registry + register_wolf/unregister_wolf

Integration: world.tscn load_steps 15→17 with DoctorProvider + WolfSpawner
nodes. world.gd registers doctor at top of provider list (priority 9 >
sleep 8 > eat 7 > construction 6 > chop≈plant 5 > mine≈craft 4 > haul 3
> rest 0). Middle bed at (47,24) marked is_medical=true.

MCP runtime verified: Bram took 75 dmg + Bleeding(2) → Downed (hp 25) →
Edda + Cora both volunteered doctor job → walked to patient → carried to
medical bed → treated → Bram healed to 94.2 hp, statuses cleared, back to
work. Wolf raid at day 3 22:00 fired; 4 wolves alive across raid cycles
by day 4 01:51. Screenshots confirm red-cross medical bed and wolf
silhouettes at night.

Phase 10 deliberately partial: wolf-side combat ships, pawn-side
weapons/armor/cover/friendly-fire deferred — full chain
(wolf→bites→pawn→bleeds→doctor) awaits player weapons.
Bleed-out timer at demo value (1200) vs design value (432000 = 6 in-game
hours) — documented in status_catalog.gd for first time-balance pass.

Delegation: Agent A (status + pawn HP), Agent B (doctor + treatment),
Agent C (wolf + spawner) — all Sonnet gdscript-refactor; integration on
Opus.

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

538 lines
22 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")
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")
# 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.
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
# 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)
@onready var dark_overlay: CanvasModulate = $DarkOverlay
@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
@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
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 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(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
# 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 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()
])
# 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)
# Wheat crops east of the cabin, near the trees.
var crop_tiles: Array[Vector2i] = [
Vector2i(54, 24), Vector2i(54, 25), Vector2i(54, 26),
Vector2i(55, 24), Vector2i(55, 25), Vector2i(55, 26),
]
for ct in crop_tiles:
var c: Crop = CROP_SCENE.instantiate()
add_child(c)
c.setup(ct, Crop.KIND_WHEAT, 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 wheat crops sown, %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())
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:
_update_dark_overlay()
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
# 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)
# ── 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())