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 # ── save / load ─────────────────────────────────────────────────────────────── ## Serialise the sparse beauty map as an array of {x, y, v} dicts. ## Only non-zero tiles are stored (map is already sparse). func save_dict() -> Dictionary: var entries: Array = [] for t in beauty_map: entries.append({"x": t.x, "y": t.y, "v": float(beauty_map[t])}) return {"beauty": entries} ## Restore the beauty map from a dict produced by save_dict(). ## Replaces the current map; furniture list is NOT restored here (entities ## re-register themselves when their nodes are re-added by the loader). func apply_dict(d: Dictionary) -> void: beauty_map.clear() for entry in d.get("beauty", []): if entry is Dictionary: var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0))) var v: float = float(entry.get("v", 0.0)) if v != 0.0: beauty_map[t] = v ## 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