rimlike/scenes/world/beauty_system.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

179 lines
6.6 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 BeautySystem extends Node
## Phase 13 — per-tile beauty score, derived from nearby furniture and its quality.
##
## beauty_map stores only tiles with non-zero beauty (sparse). Default = 0.
##
## Beauty contributions (base × quality_multiplier, spread over a radius):
## Bed +2 radius 3
## Workbench +1 radius 3
## Torch +3 radius 3
## Hearth +4 radius 3 (warm glow variant of Workbench by label_text)
##
## Falloff: linear from base at the source tile to 0 at radius+1.
## Contribution at distance d = base_beauty × multiplier × max(0, 1 - d/radius)
##
## Usage:
## Call recompute_around(tile) after any furniture build or removal.
## Query beauty_at(tile) for the aggregated beauty value at a tile.
## EventBus.tile_beauty_changed fires for every tile whose beauty changes.
##
## Wire as child of the World scene (after Pathfinder). world.gd exposes the
## instance on the World autoload as World.beauty_system for entity code.
##
## Negative beauty (corpses, etc.) is Phase 14 work — just expose bump() then.
## Sparse map: only tiles with non-zero beauty are stored.
## Keys = Vector2i tile coords; Values = float beauty score.
var beauty_map: Dictionary = {}
## Registered furniture entities. Each must expose:
## tile: Vector2i
## is_completed() -> bool (or _completed var — checked via has_method)
## label_text: String (for Hearth vs generic Workbench beauty)
## and optionally:
## quality: int (Item.Quality int; defaults to NORMAL=1 if absent)
var _furniture: Array = []
## Base beauty values by duck-typed entity type (label_text takes priority for Workbench).
const BASE_BEAUTY: Dictionary = {
"Bed": 2.0,
"Torch": 3.0,
"Hearth": 4.0,
"Workbench": 1.0, # fallback for Carpenter / Smelter / Millstone etc.
"Carpenter": 1.0,
"Smelter": 1.0,
"Millstone": 1.0,
}
## Default beauty spread radius in tiles.
const DEFAULT_RADIUS: int = 3
# ── public API ────────────────────────────────────────────────────────────────
## Returns the aggregated beauty score for `tile`. 0 if tile is not in the map.
func beauty_at(tile: Vector2i) -> float:
return float(beauty_map.get(tile, 0.0))
## Register a furniture entity so BeautySystem tracks it.
## Call from the entity's _ready() or on_build_complete hook.
func register_furniture(entity) -> void:
if not _furniture.has(entity):
_furniture.append(entity)
## Unregister a furniture entity.
## Call from the entity's _exit_tree().
func unregister_furniture(entity) -> void:
_furniture.erase(entity)
## Recompute beauty for all tiles within `radius` of `tile`.
## Call after a furniture piece is built or removed.
## Emits EventBus.tile_beauty_changed for every tile whose value changes.
func recompute_around(tile: Vector2i, radius: int = DEFAULT_RADIUS) -> void:
# Collect all tiles in the affected area (square bbox, then clamp to map).
var affected_tiles: Array[Vector2i] = []
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
affected_tiles.append(Vector2i(tile.x + dx, tile.y + dy))
_recompute_tiles(affected_tiles)
Audit.log("beauty", "recompute_around %s radius=%d" % [tile, radius])
## Full recompute across all tracked furniture. Useful on load or large changes.
## Returns immediately and logs if no furniture is registered.
func recompute_all() -> void:
if _furniture.is_empty():
return
# Collect every tile that is currently in the map OR within reach of any furniture.
var dirty_tiles: Dictionary = {}
for tile in beauty_map.keys():
dirty_tiles[tile] = true
for entity in _furniture:
if not _entity_completed(entity):
continue
var t: Vector2i = entity.tile
for dy in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1):
for dx in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1):
dirty_tiles[Vector2i(t.x + dx, t.y + dy)] = true
_recompute_tiles(dirty_tiles.keys())
# ── internal ──────────────────────────────────────────────────────────────────
## Recompute beauty for each tile in `tiles` from scratch.
## Emits tile_beauty_changed for any tile whose value changed.
func _recompute_tiles(tiles: Array) -> void:
for tile in tiles:
var new_val: float = _compute_beauty_at(tile)
var old_val: float = float(beauty_map.get(tile, 0.0))
if is_equal_approx(new_val, old_val):
continue
if new_val == 0.0:
beauty_map.erase(tile)
else:
beauty_map[tile] = new_val
EventBus.tile_beauty_changed.emit(tile, new_val)
Audit.log("beauty", "tile %s%.2f" % [tile, new_val])
## Sum all furniture contributions at a given tile.
func _compute_beauty_at(tile: Vector2i) -> float:
var total: float = 0.0
for entity in _furniture:
if not _entity_completed(entity):
continue
var et: Vector2i = entity.tile
var dist: int = abs(et.x - tile.x) + abs(et.y - tile.y)
if dist > DEFAULT_RADIUS:
continue
var base: float = _base_beauty_for(entity)
if base == 0.0:
continue
var multiplier: float = _quality_multiplier_for(entity)
var falloff: float = maxf(0.0, 1.0 - float(dist) / float(DEFAULT_RADIUS))
total += base * multiplier * falloff
return total
## Returns the base beauty value for an entity by its label_text (or type name).
func _base_beauty_for(entity) -> float:
var label: String = ""
if "label_text" in entity:
label = str(entity.label_text)
# Check specific labels first, then fall back to class_name.
if BASE_BEAUTY.has(label):
return float(BASE_BEAUTY[label])
# Fallback by class_name string (e.g. "Bed", "Torch").
var cname: String = entity.get_class()
if BASE_BEAUTY.has(cname):
return float(BASE_BEAUTY[cname])
return 0.0
## Returns the quality multiplier for an entity.
## Uses Item.Quality int (0=SHODDY..4=LEGENDARY) → 0.5/1.0/1.5/2.0/2.5.
## Defaults to 1.0 (NORMAL) if the entity has no quality field.
func _quality_multiplier_for(entity) -> float:
var q: int = 1 # NORMAL default
if "quality" in entity:
q = int(entity.quality)
match q:
0: return 0.5 # SHODDY
1: return 1.0 # NORMAL
2: return 1.5 # EXCELLENT
3: return 2.0 # MASTERWORK
4: return 2.5 # LEGENDARY
_: return 1.0
## Returns true if `entity` has finished building.
## Checks the _completed bool directly (all furniture exposes is_completed() and the var).
func _entity_completed(entity) -> bool:
if entity.has_method("is_completed"):
return entity.is_completed()
if "_completed" in entity:
return bool(entity._completed)
return true # assume complete if we can't tell