rimlike/scenes/world/room_detector.gd
megaproxy 9cf9b7dbfd Phase 13: Rooms + Auto-roof + Beauty + Dirtiness + Cleaning
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>
2026-05-11 17:19:23 +01:00

256 lines
9.4 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.

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