extends Node ## Runtime entity registry + tile-related sim state. ## ## All gameplay entities (pawns, items, furniture, animals, corpses) live here. ## TileMap data is owned by the world-view scene; World holds the *indirect* ## state (designation queue, dirty-haul set, zone records, etc.) that doesn't ## belong on the TileMap itself. ## ## See docs/architecture.md. # Phase 2 — pawn registry. items/furniture/animals/corpses arrive in later phases. var pawns: Array[Pawn] = [] # Phase 3 — work providers (e.g. RestProvider, ChopProvider, HaulingProvider). # World scene registers them on _ready. Decision.pick_next_job() iterates by .priority desc. var work_providers: Array = [] # Phase 4 — harvestables + items + stockpiles. Entities call register_*/unregister_* # from their _ready/_exit_tree. Phase 16 will add stable IDs and persistence wiring. var trees: Array = [] # Array of Tree var rocks: Array = [] # Array of Rock var items: Array = [] # Array of Item (on-floor stacks) var stockpiles: Array = [] # Array of StorageDestination (StockpileZone for now; containers Phase 5) # Phase 4 — pathfinder reference exposed for entity code that needs walkability # checks (e.g. Tree.fell() picking neighbour tiles for wood drops). The actual # Pathfinder node lives on the World scene as a child; the scene sets this in # its _ready(). Don't access before the world scene is mounted. var pathfinder = null ## Reference to the in-scene Designation controller. Wired by world.gd _ready ## so entities completing a job can call World.clear_designation_at(tile) to ## remove the lingering ghost paint without depending on the scene tree path. var designation_ctl = null # Phase 5 — build queue. Holds Wall/Floor/Door/Crate ghost entities (not yet # completed). ConstructionProvider iterates this for the nearest buildable site. # Entities call register_build_site() in _ready and unregister_build_site() when # they finish or are cancelled. var build_queue: Array = [] # Phase 5 — completed Door entities, keyed for future open/close logic. # Door._complete() calls register_door(); Phase 7+ uses this for toggling. var doors: Array = [] # Phase 6 — workbench entities. Workbench._ready() calls register_workbench(); # _exit_tree() calls unregister_workbench(). CraftingProvider iterates this # to find bench+bill pairs for eligible pawns. var workbenches: Array = [] # Phase 7 — crop entities. Crop._ready() calls register_crop(); # _exit_tree() calls unregister_crop(). PlantProvider iterates this to find # harvestable (READY) and sowable (TILLED) crops for eligible pawns. var crops: Array = [] # Phase 8 — bed entities. Bed._ready() calls register_bed(); # _exit_tree() calls unregister_bed(). SleepProvider iterates this to find # available (completed, unoccupied) beds for tired pawns. # Storyteller also reads beds.size() for the "First Beds" state predicate. var beds: Array = [] # Phase 11 — light-source entities (Torch + Hearth workbench). Entities call # register_light_source() in _ready and unregister_light_source() in _exit_tree. # is_tile_lit() is queried by the "in darkness" thought and any future # darkness-rendering shader bridge. All entries expose the duck-type interface: # is_on() → bool | get_light_tile() → Vector2i | get_light_radius() → int var light_sources: Array = [] # Phase 10 — wolf entities. Wolf._ready() calls register_wolf(); # Wolf._exit_tree() calls unregister_wolf(). WolfSpawner reads/writes # nothing from this array directly — it only add_child()s new wolves. # CombatSystem (Phase 10) will iterate this for threat detection. # Untyped array — avoids class_name ordering window (Phase 2 gotcha). var wolves: Array = [] # Phase 14 — corpse entities + grave markers. Corpse._ready() calls # register_corpse() / unregister_corpse() (on rot, burial, or cremation). # GraveMarker is the permanent post-burial entity; markers persist for the # duration of the save. var corpses: Array = [] var grave_markers: Array = [] # Phase 4 — hauling dirty set. Keys are Items, value is unused (we just use .keys()). # An Item is added when it spawns (Tree.fell, Rock.mined, workbench drop, ...) # and removed when it lands at its highest-priority valid destination. # HaulingProvider.sweep_for_better_destinations() re-marks items when a higher # priority stockpile opens up (the priority cascade per design.md). var items_needing_haul: Dictionary = {} ## Clear the designation ghost at `tile`, if any. Entities call this from ## their _complete / fell / mined handlers so the visual highlight disappears ## once the job is done. Safe no-op if designation_ctl isn't wired (headless). func clear_designation_at(tile: Vector2i) -> void: if designation_ctl != null: designation_ctl.clear_cell(tile) func register_work_provider(wp) -> void: assert(wp != null, "World.register_work_provider: provider is null") if not work_providers.has(wp): work_providers.append(wp) func clear_work_providers() -> void: work_providers.clear() func register_pawn(p: Pawn) -> void: assert(p != null, "World.register_pawn: pawn is null") if pawns.has(p): return pawns.append(p) func unregister_pawn(p: Pawn) -> void: pawns.erase(p) func pawn_at_tile(tile: Vector2i) -> Pawn: for p in pawns: if p.tile == tile: return p return null ## Returns the Workbench occupying `tile`, or null if none. Used by Selection ## to route taps on a workbench to the bill-editor panel. func workbench_at_tile(tile: Vector2i): for w in workbenches: if w.tile == tile: return w return null func clear_pawns() -> void: # For save-load / new-game flow in Phase 16. pawns.clear() ## Phase 16 — wipe every entity registry on the autoload AND queue_free the ## live Nodes inside the World scene so apply_save can rebuild from scratch. ## The World *scene* (TileMapLayers, providers, autoloads) survives — only the ## per-entity registrations + their Node2D children get cleared. ## ## Safe to call between sim ticks. Called by SaveSystem.apply_save. func clear_all() -> void: # 1. queue_free every registered entity Node so it leaves the tree. var to_free: Array = [] to_free.append_array(pawns) to_free.append_array(items) to_free.append_array(trees) to_free.append_array(rocks) to_free.append_array(build_queue) # ghost entities to_free.append_array(doors) to_free.append_array(workbenches) to_free.append_array(crops) to_free.append_array(beds) to_free.append_array(light_sources) to_free.append_array(wolves) to_free.append_array(corpses) to_free.append_array(grave_markers) to_free.append_array(stockpiles) for ent in to_free: if ent != null and is_instance_valid(ent) and ent.has_method("queue_free"): ent.queue_free() # 2. Clear every registry (entity _exit_tree handlers will also try to erase # themselves; double-clear is harmless on a Dictionary/Array). pawns.clear() items.clear() trees.clear() rocks.clear() build_queue.clear() doors.clear() workbenches.clear() crops.clear() beds.clear() light_sources.clear() wolves.clear() corpses.clear() grave_markers.clear() stockpiles.clear() items_needing_haul.clear() rooms.clear() # BeautySystem / DirtinessSystem maps survive (their owning Nodes do too); # their .clear_all() helpers (if any) live on the system itself. Audit.log("world", "clear_all: registries wiped, %d nodes queue_freed" % to_free.size()) # ── Phase 4: harvestables + items + stockpiles ────────────────────────────── func register_tree(t) -> void: if not trees.has(t): trees.append(t) func unregister_tree(t) -> void: trees.erase(t) func register_rock(r) -> void: if not rocks.has(r): rocks.append(r) func unregister_rock(r) -> void: rocks.erase(r) func register_item(it) -> void: if items.has(it): return items.append(it) # Newly-spawned items always start as "needs haul" — HaulingProvider will # clear the flag once the item lands in its highest-priority destination. items_needing_haul[it] = true func unregister_item(it) -> void: items.erase(it) items_needing_haul.erase(it) func register_stockpile(s) -> void: if not stockpiles.has(s): stockpiles.append(s) func unregister_stockpile(s) -> void: stockpiles.erase(s) func mark_item_needs_haul(it) -> void: items_needing_haul[it] = true func clear_item_haul_flag(it) -> void: items_needing_haul.erase(it) # ── Phase 5: build queue + tile-data stamping for walls / floors ──────────── func register_build_site(entity) -> void: if not build_queue.has(entity): build_queue.append(entity) func unregister_build_site(entity) -> void: build_queue.erase(entity) func register_door(d) -> void: if not doors.has(d): doors.append(d) func unregister_door(d) -> void: doors.erase(d) func register_workbench(wb) -> void: if not workbenches.has(wb): workbenches.append(wb) func unregister_workbench(wb) -> void: workbenches.erase(wb) func register_crop(c) -> void: if not crops.has(c): crops.append(c) func unregister_crop(c) -> void: crops.erase(c) func register_bed(b) -> void: if not beds.has(b): beds.append(b) func unregister_bed(b) -> void: beds.erase(b) # ── Phase 11: light-source registry ──────────────────────────────────────── func register_light_source(ls) -> void: if not light_sources.has(ls): light_sources.append(ls) func unregister_light_source(ls) -> void: light_sources.erase(ls) # ── Phase 10: wolf registry ──────────────────────────────────────────────── func register_wolf(w) -> void: if not wolves.has(w): wolves.append(w) func unregister_wolf(w) -> void: wolves.erase(w) # ── Phase 14: corpses + grave markers ─────────────────────────────────────── func register_corpse(c) -> void: if not corpses.has(c): corpses.append(c) func unregister_corpse(c) -> void: corpses.erase(c) func register_grave_marker(gm) -> void: if not grave_markers.has(gm): grave_markers.append(gm) func unregister_grave_marker(gm) -> void: grave_markers.erase(gm) ## Returns the first Corpse covering `tile`, or null. func corpse_at_tile(tile: Vector2i): for c in corpses: if c.tile == tile: return c return null ## Returns true if `tile` is within get_light_radius() of any is_on() light ## source. Uses Manhattan distance (no wall-occlusion in Phase 11; Phase 13 ## may add BFS-based occlusion through the room/roof system). ## ## Called by the "in darkness" Thought trigger on each pawn sim tick. ## O(light_sources) per call; trivial at our scale (< 50 sources in MVP). func is_tile_lit(p_tile: Vector2i) -> bool: for ls in light_sources: if not ls.is_on(): continue var d: int = abs(ls.get_light_tile().x - p_tile.x) + abs(ls.get_light_tile().y - p_tile.y) if d <= ls.get_light_radius(): return true return false # Called by Wall.on_build_tick() when construction completes. # Stamps the data-only Wall TileMap layer so room/roof/save logic sees the # wall. World scene exposes wall_layer via a getter set during _ready. var terrain_layer = null var wall_layer = null var floor_layer = null var designation_layer = null var roof_layer = null func mark_wall_tile(tile: Vector2i, material: StringName) -> void: if wall_layer == null: Audit.log("world", "mark_wall_tile: layer not yet wired — skipping") return # Atlas coord encodes material — for Phase 5 placeholder atlas: # stone → (2, 0), dark stone → (3, 0) # Real material→atlas mapping lands when assets are imported. var atlas := Vector2i(2, 0) if material == &"stone" else Vector2i(3, 0) wall_layer.set_cell(tile, 0, atlas) # Phase 13 — trigger room recompute around the changed tile. if room_detector != null: room_detector.recompute_around(tile) func mark_floor_tile(tile: Vector2i, material: StringName) -> void: if floor_layer == null: return var atlas := Vector2i(1, 0) if material == &"dirt" else Vector2i(2, 0) floor_layer.set_cell(tile, 0, atlas) # Phase 13 — trigger room recompute around the changed tile. if room_detector != null: room_detector.recompute_around(tile) ## Phase 13 — Called by Door._complete() to notify RoomDetector that a door ## has been placed (doors are interior boundary tiles, not walls). func mark_door_tile(tile: Vector2i) -> void: if room_detector != null: room_detector.recompute_around(tile) ## Phase 13 — Toggle a tile's no-roof designation. Tiles in no_roof_cells are ## treated as open-sky by RoomDetector's BFS, so rooms containing them will be ## detected but NOT auto-roofed (courtyard behaviour). ## This is also the test-helper entry point the spec asks for: ## World.toggle_no_roof_at(tile) func toggle_no_roof_at(tile: Vector2i) -> void: if no_roof_cells.has(tile): no_roof_cells.erase(tile) Audit.log("room", "no-roof cleared at %s" % tile) else: no_roof_cells[tile] = true Audit.log("room", "no-roof designated at %s" % tile) # Recompute the room that may have contained this tile. if room_detector != null: room_detector.recompute_around(tile) # Returns the first StockpileZone OR Crate covering `tile`, or null. # Used by JobRunner._tick_deposit (Phase 5 refactor) to route deposits into # Crate contents when applicable. func stockpile_at_tile(tile: Vector2i): for sp in stockpiles: if sp.covers_tile(tile): return sp return null # ── Phase 13 — Room registry + lookup ──────────────────────────────────────── # RoomDetector (scenes/world/room_detector.gd) populates this dict. # Keys = stable room ids (int), values = Room class instances. # Callers should treat this as read-only. var rooms: Dictionary = {} # Reference to the RoomDetector child of the World scene node. # Set by World._ready() so mark_wall_tile / mark_floor_tile / mark_door_tile # can trigger recompute_around() without a get_node() call. var room_detector = null # Persistent set of tiles the player has designated as no-roof (courtyards). # Keys are Vector2i, value is unused boolean. Written by toggle_no_roof_at(). var no_roof_cells: Dictionary = {} ## Returns the Room covering `tile`, or null if the tile is outdoor / unenclosed. ## O(rooms) bounds-checked sweep — cheap at MVP scale (< 20 rooms). func room_at_tile(tile: Vector2i): for id in rooms: var r = rooms[id] if r.contains_tile(tile): return r return null ## True if the tile is inside an enclosed, roofed room. Replaces Phase 12's ## "has floor below" shelter proxy — Pawn._is_sheltered() will reroute here once ## RoomDetector is live. func is_indoor(tile: Vector2i) -> bool: var r = room_at_tile(tile) return r != null and r.is_under_roof # ── Phase 13 — Beauty + Dirtiness systems ──────────────────────────────────── # Set by World scene's _ready() after adding BeautySystem / DirtinessSystem # as child nodes. All callers use World.get("beauty_system") defensively. ## BeautySystem child of the World scene. ## Exposes: beauty_at(tile), register_furniture(entity), recompute_around(tile). var beauty_system = null ## DirtinessSystem child of the World scene. ## Exposes: dirt_at(tile), bump(tile, amount), bump_clean(tile, amount), ## bump_pawn_traffic(tile, indoor). var dirtiness_system = null # ── Phase 16: TileMap layer serialisation helpers ──────────────────────────── ## Serialize the five sim-relevant TileMap layers (Terrain, Floor, Wall, ## Designation, Roof) into a plain Dictionary. Fog layer is runtime-only and ## is NOT serialized. ## ## Returns: ## { ## "terrain": [{x, y, source_id, atlas_x, atlas_y}, ...], ## "floor": [...], ## "wall": [...], ## "designation": [...], ## "roof": [...], ## } ## ## Called by SaveSystem.build_save() between sim ticks. func save_tilemap_layers() -> Dictionary: var result: Dictionary = {} var layer_map: Dictionary = { "terrain": terrain_layer, "floor": floor_layer, "wall": wall_layer, "designation": designation_layer, "roof": roof_layer, } for layer_name in layer_map: var layer = layer_map[layer_name] var entries: Array = [] if layer == null: result[layer_name] = entries continue for cell in layer.get_used_cells(): var src_id: int = layer.get_cell_source_id(cell) var atlas: Vector2i = layer.get_cell_atlas_coords(cell) entries.append({ "x": cell.x, "y": cell.y, "source_id": src_id, "atlas_x": atlas.x, "atlas_y": atlas.y, }) result[layer_name] = entries return result ## Restore the five TileMap layers from a dict produced by save_tilemap_layers(). ## Clears each layer first, then stamps every saved cell. ## ## Called by SaveSystem.apply_save() before respawning entities. func apply_tilemap_layers(d: Dictionary) -> void: var layer_map: Dictionary = { "terrain": terrain_layer, "floor": floor_layer, "wall": wall_layer, "designation": designation_layer, "roof": roof_layer, } for layer_name in layer_map: var layer = layer_map[layer_name] if layer == null: continue layer.clear() for entry in d.get(layer_name, []): if not entry is Dictionary: continue var cell := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0))) var src_id: int = int(entry.get("source_id", 0)) var atlas := Vector2i(int(entry.get("atlas_x", 0)), int(entry.get("atlas_y", 0))) layer.set_cell(cell, src_id, atlas)