Three-agent fan-out — Opus pre-wrote Room class, World.rooms/room_at_tile/is_indoor, 4 EventBus signals before dispatch so the slices ran fully parallel. DECISION: Big-room UX = bump auto-roof cap to 16, banner above. Cabin (24 tiles) intentionally exceeds cap to exercise the warning path; a 5×5 test shed (9 interior tiles) was added to exercise the roof path. Room detection (Agent A): - scenes/world/room.gd — class_name Room, tiles/bounds/is_under_roof, contains_tile() bounds-then-list-checked, recompute_bounds() - scenes/world/room_detector.gd — class_name RoomDetector, BFS 4-dir from floor/door tiles, walls/terrain as boundary, doors counted as room interior. Detects up to 4× cap; auto-roofs only ≤16. - World.mark_wall_tile/mark_floor_tile/mark_door_tile hook BFS recompute - Door._complete() now erases wall-layer stamp + registers door tile - Designation.TOOL_NO_ROOF paint mode wired (UI button deferred Phase 17) - EventBus.room_changed / room_too_large signals Indoor/Shelter (Agent B): - Pawn._is_sheltered() rerouted: World.is_indoor() first, floor-proxy fallback - IndoorTintOverlay Node2D — _draw fills roofed-room tiles at α=0.10 warm - Crop._on_sim_tick skips stage advance when World.is_indoor(tile) Beauty + Dirtiness + Cleaning + Room thoughts (Agent C): - BeautySystem sparse map, linear falloff radius=3, Quality multiplier (SHODDY 0.5 → LEGENDARY 2.5). Base: Bed +2, Workbench +1, Torch +3, Hearth +4 - DirtinessSystem 0-100, tier crossings (clean<25/dirty<60/filthy≥60) emit tile_dirtiness_changed. bump/bump_clean/bump_pawn_traffic API - CleaningProvider priority=2, KIND_CLEAN toil, 2.5 dirt/tick for ~40 ticks - Bed/Torch/Workbench _complete() now register with BeautySystem - 7 room mood thoughts: clean_room (+2), dirty_room (-3), filthy_room (-6), beautiful_room (+4), ugly_room (-3), slept_in_room (+3 EVENT, wires Ph 17), ate_without_table (-3 EVENT, wires Ph 17) - Pawn._sync_room_thoughts called from _process_thoughts after cold block, defensive against null rooms/systems Integration recovery (Opus): - Agent C's BeautySystem/DirtinessSystem/CleaningProvider/IndoorTintOverlay instantiation in world.gd never landed (only field declarations + entity hooks survived). Added preloads + runtime add_child + autoload bindings + CleaningProvider registration + furniture pre-seed in _ready - Added _prestamp_test_shed_for_room_detector with _spawn_complete_wall/floor helpers so a 5×5 visible shed exercises the auto-roof path at boot MCP runtime verified: - Rooms: cabin Room#2 size=24 roofed=false (room_too_large fires), shed Room#3 size=9 roofed=true (auto-roof active) - beauty_map size=50 around prebuilt furniture; bed at (47,24) beauty=4.0 - Bram teleported to (36, 25) in shed → indoor=true, sheltered=true, thoughts=[clean_room +2], mood=52.0 - Screenshot: shed walls + brown floor visible; cabin warmly torch-lit; Spring 1/12 indicator; Day 1 07:52 Delegation: 3× gdscript-refactor (Sonnet) agents in parallel; integration recovery + MCP verify on Opus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
9.4 KiB
GDScript
256 lines
9.4 KiB
GDScript
class_name RoomDetector extends Node
|
||
## Phase 13 — BFS-based room detection, auto-roof, and no-roof exclusion.
|
||
##
|
||
## RoomDetector owns the lifecycle of Room objects: it creates them, destroys
|
||
## them when the geometry changes, and writes `World.rooms`. Nothing else
|
||
## should mutate `World.rooms` directly.
|
||
##
|
||
## Entry point: call `recompute_around(tile)` after any wall/floor/door build or
|
||
## removal. World._ready() calls it once for the cabin demo seed so MCP runtime
|
||
## tests can see a Room in the registry immediately after boot.
|
||
##
|
||
## No-roof exclusion: tiles in `World.no_roof_cells` are treated as "open sky"
|
||
## during BFS. If any such tile appears in a BFS result the room is detected
|
||
## but marked `is_under_roof = false` regardless of size.
|
||
##
|
||
## Delegation model: this node IS a child of the World scene node (not the
|
||
## World autoload). `World.room_detector` is a convenience reference set in
|
||
## World._ready() so autoloads can call recompute_around().
|
||
|
||
## Give enough BFS headroom to detect rooms up to 4× the auto-roof cap.
|
||
## BFS counts exceeding this without finding a wall ring → treated as outdoor.
|
||
const BFS_CAP_FOR_ROOM: int = Room.ROOM_AUTOROOF_CAP * 4
|
||
|
||
## Radius (in tiles) around a changed tile to re-evaluate.
|
||
const RECOMPUTE_RADIUS: int = 3
|
||
|
||
## 4-directional offsets (no diagonals — matches pathfinder convention).
|
||
const _DIRS: Array[Vector2i] = [
|
||
Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0),
|
||
]
|
||
|
||
## Map bounds — set by World._ready() after pathfinder is configured.
|
||
var _map_size: Vector2i = Vector2i(80, 80)
|
||
|
||
## Monotonically-increasing id counter. Invalidated ids are never reused.
|
||
var _next_room_id: int = 0
|
||
|
||
|
||
# ── public API ────────────────────────────────────────────────────────────────
|
||
|
||
## Set the canonical map size so BFS can detect edge-of-map as "outdoor".
|
||
func setup(map_size: Vector2i) -> void:
|
||
_map_size = map_size
|
||
|
||
|
||
## Main entry point. Call after any wall/floor/door change at `changed_tile`.
|
||
## Invalidates rooms that overlap the 3-tile radius, then re-BFS from every
|
||
## open floor tile in that radius.
|
||
func recompute_around(changed_tile: Vector2i) -> void:
|
||
# 1. Gather the recompute radius set.
|
||
var radius_tiles: Array[Vector2i] = _tiles_in_radius(changed_tile, RECOMPUTE_RADIUS)
|
||
|
||
# 2. Destroy any Room whose tiles overlap the radius.
|
||
_invalidate_rooms_touching(radius_tiles)
|
||
|
||
# 3. For each floor or door tile in the radius that isn't a wall, try a BFS.
|
||
# Track which tiles we've already started a BFS from so we don't start two
|
||
# BFS runs from tiles that will find the same room.
|
||
var already_claimed: Dictionary = {} # Vector2i → true, for tiles inside a found room.
|
||
|
||
for candidate in radius_tiles:
|
||
if already_claimed.has(candidate):
|
||
continue
|
||
if not _is_floor_or_door(candidate):
|
||
continue
|
||
|
||
var result: Dictionary = _bfs_room(candidate)
|
||
if result.is_empty():
|
||
continue # outdoor or BFS cap hit without enclosure
|
||
|
||
var found_tiles: Array[Vector2i] = result["tiles"]
|
||
var has_no_roof: bool = result["has_no_roof"]
|
||
|
||
# Mark all found tiles so sibling candidates skip them.
|
||
for t in found_tiles:
|
||
already_claimed[t] = true
|
||
|
||
_create_room(found_tiles, has_no_roof)
|
||
|
||
|
||
# ── room invalidation ─────────────────────────────────────────────────────────
|
||
|
||
func _invalidate_rooms_touching(radius_tiles: Array[Vector2i]) -> void:
|
||
var radius_set: Dictionary = {}
|
||
for t in radius_tiles:
|
||
radius_set[t] = true
|
||
|
||
# Collect ids to destroy (can't erase while iterating).
|
||
var to_destroy: Array[int] = []
|
||
for id in World.rooms:
|
||
var r: Room = World.rooms[id]
|
||
for t in r.tiles:
|
||
if radius_set.has(t):
|
||
to_destroy.append(id)
|
||
break
|
||
|
||
for id in to_destroy:
|
||
World.rooms.erase(id)
|
||
EventBus.room_changed.emit(id)
|
||
Audit.log("room", "destroyed room #%d" % id)
|
||
|
||
|
||
# ── BFS ───────────────────────────────────────────────────────────────────────
|
||
|
||
## Returns a dict {"tiles": Array[Vector2i], "has_no_roof": bool} when a clean
|
||
## enclosed room is found. Returns an empty dict for outdoor / over-cap areas.
|
||
##
|
||
## Door semantics: a door tile is a BOUNDARY like a wall — BFS stops at it and
|
||
## does NOT expand through it to the outside. The door tile IS included in the
|
||
## room's tile list (it's part of the interior enclosure). This matches the
|
||
## spec: "treat door tiles as boundary cells" that stop the BFS but are counted.
|
||
func _bfs_room(start: Vector2i) -> Dictionary:
|
||
# interior_tiles: tiles that are inside the room (floor + door tiles).
|
||
# boundary_visited: tiles that stopped BFS expansion (walls + doors), tracked
|
||
# so we don't re-process them as neighbours.
|
||
var interior_visited: Dictionary = {} # Vector2i → true
|
||
var boundary_visited: Dictionary = {} # Vector2i → true (walls + doors)
|
||
var queue: Array[Vector2i] = [start]
|
||
interior_visited[start] = true
|
||
var has_no_roof: bool = false
|
||
|
||
while queue.size() > 0:
|
||
if interior_visited.size() > BFS_CAP_FOR_ROOM:
|
||
# Exceeded cap without finding a closed wall ring → treat as outdoor.
|
||
return {}
|
||
|
||
var current: Vector2i = queue.pop_front()
|
||
|
||
# Check for no-roof designation.
|
||
if World.no_roof_cells.has(current):
|
||
has_no_roof = true
|
||
|
||
for dir in _DIRS:
|
||
var neighbour: Vector2i = current + dir
|
||
|
||
# Already classified — skip.
|
||
if interior_visited.has(neighbour) or boundary_visited.has(neighbour):
|
||
continue
|
||
|
||
# Map edge → open sky → discard entire BFS.
|
||
if not _in_bounds(neighbour):
|
||
return {}
|
||
|
||
if _is_wall(neighbour):
|
||
# Wall is a valid enclosing boundary — mark as boundary, stop expansion.
|
||
boundary_visited[neighbour] = true
|
||
continue
|
||
|
||
if _is_door(neighbour):
|
||
# Door acts as a boundary that stops BFS from escaping, but the door
|
||
# tile itself is part of the room interior. Add to interior but do
|
||
# NOT expand through it — treat it like a wall for expansion purposes.
|
||
interior_visited[neighbour] = true
|
||
# Do NOT append to queue — door blocks outward traversal.
|
||
continue
|
||
|
||
if _is_floor(neighbour):
|
||
interior_visited[neighbour] = true
|
||
queue.append(neighbour)
|
||
else:
|
||
# Bare terrain → open sky → discard.
|
||
return {}
|
||
|
||
if interior_visited.is_empty():
|
||
return {}
|
||
|
||
var interior: Array[Vector2i] = []
|
||
for t: Vector2i in interior_visited.keys():
|
||
interior.append(t)
|
||
|
||
return {"tiles": interior, "has_no_roof": has_no_roof}
|
||
|
||
|
||
# ── room creation ─────────────────────────────────────────────────────────────
|
||
|
||
func _create_room(interior: Array[Vector2i], has_no_roof: bool) -> void:
|
||
var r := Room.new()
|
||
r.id = _next_room_id
|
||
_next_room_id += 1
|
||
r.tiles = interior
|
||
r.recompute_bounds()
|
||
|
||
var count: int = interior.size()
|
||
var top_left: Vector2i = Vector2i(r.bounds.position.x, r.bounds.position.y)
|
||
|
||
if has_no_roof:
|
||
# No-roof tile inside — courtyard; detects as room but stays open.
|
||
r.is_under_roof = false
|
||
World.rooms[r.id] = r
|
||
EventBus.room_changed.emit(r.id)
|
||
Audit.log("room", "discovered #%d size=%d at bounds=%s (no-roof courtyard)" % [
|
||
r.id, count, r.bounds
|
||
])
|
||
|
||
elif count <= Room.ROOM_AUTOROOF_CAP:
|
||
r.is_under_roof = true
|
||
World.rooms[r.id] = r
|
||
EventBus.room_changed.emit(r.id)
|
||
Audit.log("room", "discovered #%d size=%d at bounds=%s (auto-roofed)" % [
|
||
r.id, count, r.bounds
|
||
])
|
||
|
||
else:
|
||
# Clean enclosure but over the auto-roof cap.
|
||
r.is_under_roof = false
|
||
World.rooms[r.id] = r
|
||
EventBus.room_too_large.emit(top_left, count)
|
||
EventBus.room_changed.emit(r.id)
|
||
Audit.log("room", "WARNING room #%d too large size=%d at bounds=%s (roof suppressed)" % [
|
||
r.id, count, r.bounds
|
||
])
|
||
|
||
|
||
# ── tile classification helpers ───────────────────────────────────────────────
|
||
|
||
func _in_bounds(tile: Vector2i) -> bool:
|
||
return tile.x >= 0 and tile.y >= 0 and tile.x < _map_size.x and tile.y < _map_size.y
|
||
|
||
|
||
## True if the tile has a wall stamp on the Wall TileMapLayer.
|
||
func _is_wall(tile: Vector2i) -> bool:
|
||
if World.wall_layer == null:
|
||
return false
|
||
return World.wall_layer.get_cell_source_id(tile) != -1
|
||
|
||
|
||
## True if the tile has a floor stamp on the Floor TileMapLayer.
|
||
func _is_floor(tile: Vector2i) -> bool:
|
||
if World.floor_layer == null:
|
||
return false
|
||
return World.floor_layer.get_cell_source_id(tile) != -1
|
||
|
||
|
||
## True if the tile is a completed door entity (World.doors registry).
|
||
## Doors act as room boundaries in the BFS (walkable but block outward expansion).
|
||
func _is_door(tile: Vector2i) -> bool:
|
||
for door in World.doors:
|
||
if door.tile == tile and door.is_completed():
|
||
return true
|
||
return false
|
||
|
||
|
||
## Convenience: true if tile is floor OR door. Used to seed BFS start points.
|
||
func _is_floor_or_door(tile: Vector2i) -> bool:
|
||
return _is_floor(tile) or _is_door(tile)
|
||
|
||
|
||
## Collect all tiles within `radius` Manhattan distance of `center`.
|
||
func _tiles_in_radius(center: Vector2i, radius: int) -> Array[Vector2i]:
|
||
var result: Array[Vector2i] = []
|
||
for dx in range(-radius, radius + 1):
|
||
for dy in range(-radius, radius + 1):
|
||
var t := center + Vector2i(dx, dy)
|
||
if _in_bounds(t):
|
||
result.append(t)
|
||
return result
|