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