class_name DirtinessSystem extends Node ## Phase 13 — per-tile dirtiness accumulation and tier tracking. ## ## dirt_map: Dictionary[Vector2i, float], 0..100 scale. Only tiles with non-zero ## dirt are keyed (sparse). ## ## Public API: ## bump(tile, amount) — add dirtiness to a tile. Positive adds dirt; use ## negative values to reduce (cleaning does this via ## bump_clean). Phase 14 calls bump(tile, +5) per hour ## for corpse decay and bump(tile, +20) for combat blood. ## bump_clean(tile, amount) — specialised helper for the CleaningProvider; ## reduces dirt and removes the tile from the map at 0. ## dirt_at(tile) — returns the current dirtiness for a tile (0.0 default). ## ## Tier thresholds (per design.md): ## Clean < 25 ## Dirty 25..60 ## Filthy >= 60 ## ## EventBus.tile_dirtiness_changed fires on TIER crossings only, not every bump. ## This keeps signal volume low since bumps fire 20×/s per pawn crossing. ## ## Pawn tile-change hook: ## World scene calls bump_pawn_traffic(tile, indoor) each time a pawn advances ## a tile. Indoor traffic adds 0.2; outdoor-tracked-in adds 0.5. ## (world.gd bridges _advance_walk → DirtinessSystem via the arrived_at_destination ## signal on each Pawn — wired in world.gd _spawn_sample_pawns / _on_pawn_ready.) ## ## Wire as child of the World scene (after Pathfinder). world.gd exposes the ## instance on the World autoload as World.dirtiness_system for entity code. ## Sparse map: only tiles with non-zero dirt are stored. ## Keys = Vector2i tile coords; Values = float in [0, 100]. var dirt_map: Dictionary = {} ## Tier boundaries (thresholds match design.md; tune Phase 20). const DIRT_DIRTY_THRESHOLD: float = 25.0 const DIRT_FILTHY_THRESHOLD: float = 60.0 ## Traffic bump amounts per pawn step. const BUMP_OUTDOOR_TRACKED: float = 0.5 # boots bringing dirt in from outside const BUMP_INDOOR_TRAFFIC: float = 0.2 # indoor walking # ── public API ──────────────────────────────────────────────────────────────── ## Returns the dirtiness score for `tile` (0.0 if clean / not in map). func dirt_at(tile: Vector2i) -> float: return float(dirt_map.get(tile, 0.0)) ## Add `amount` of dirt to `tile`. Clamped to [0, 100]. ## Emits EventBus.tile_dirtiness_changed when a tier boundary is crossed. ## Phase 14 calls this for blood (+20) and corpse decay (+5/h). func bump(tile: Vector2i, amount: float) -> void: var old_val: float = float(dirt_map.get(tile, 0.0)) var new_val: float = clampf(old_val + amount, 0.0, 100.0) if is_equal_approx(new_val, old_val): return _set_dirt(tile, old_val, new_val) ## Reduce dirt by `amount`, flooring at 0. Removes tile from map when clean. ## Used by CleaningProvider's KIND_CLEAN toil each tick. func bump_clean(tile: Vector2i, amount: float) -> void: var old_val: float = float(dirt_map.get(tile, 0.0)) if old_val <= 0.0: return var new_val: float = maxf(0.0, old_val - amount) _set_dirt(tile, old_val, new_val) ## Traffic-driven dirt bump. Called by World when a pawn arrives at a new tile. ## `indoor` = true when the tile is inside a roofed room (World.is_indoor check). func bump_pawn_traffic(tile: Vector2i, indoor: bool) -> void: var amount := BUMP_INDOOR_TRAFFIC if indoor else BUMP_OUTDOOR_TRACKED bump(tile, amount) # ── save / load ─────────────────────────────────────────────────────────────── ## Serialise the sparse dirt 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 dirt_map: entries.append({"x": t.x, "y": t.y, "v": float(dirt_map[t])}) return {"dirtiness": entries} ## Restore the dirt map from a dict produced by save_dict(). ## Replaces the current map in full. func apply_dict(d: Dictionary) -> void: dirt_map.clear() for entry in d.get("dirtiness", []): if entry is Dictionary: var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0))) var v: float = clampf(float(entry.get("v", 0.0)), 0.0, 100.0) if v > 0.0: dirt_map[t] = v # ── internal ────────────────────────────────────────────────────────────────── ## Apply a dirt change from old_val to new_val for `tile`. ## Updates the map and emits the tier-crossing signal when the tier changes. func _set_dirt(tile: Vector2i, old_val: float, new_val: float) -> void: var old_tier: int = _tier_for(old_val) var new_tier: int = _tier_for(new_val) if new_val <= 0.0: dirt_map.erase(tile) else: dirt_map[tile] = new_val if old_tier != new_tier: EventBus.tile_dirtiness_changed.emit(tile, new_val) Audit.log( "dirtiness", "tile %s tier %d → %d (dirt=%.1f)" % [tile, old_tier, new_tier, new_val] ) ## Returns the dirtiness tier for a given value. ## 0 = clean, 1 = dirty, 2 = filthy. func _tier_for(val: float) -> int: if val >= DIRT_FILTHY_THRESHOLD: return 2 if val >= DIRT_DIRTY_THRESHOLD: return 1 return 0