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>
This commit is contained in:
parent
92f4e5c945
commit
9cf9b7dbfd
28 changed files with 1286 additions and 28 deletions
|
|
@ -25,3 +25,9 @@ signal pawn_status_removed(pawn, status) ## Emitted by Pawn.remove_status_
|
|||
# Phase 12 — Seasons + Weather.
|
||||
signal season_changed(season: StringName) ## Emitted by Clock when current_season() rolls over (Spring → Summer → Autumn → Winter).
|
||||
signal weather_changed(weather: StringName) ## Emitted by Weather autoload when the daily roll resolves to a new weather kind.
|
||||
|
||||
# Phase 13 — Rooms + Roofing + Beauty + Dirtiness + Cleaning.
|
||||
signal room_changed(room_id: int) ## Emitted when a room is created, destroyed, or recomputed (id may be invalid post-destroy).
|
||||
signal room_too_large(top_left: Vector2i, cell_count: int) ## Emitted when BFS hits ROOM_MAX_CELLS — surfaces the "split with interior wall" banner.
|
||||
signal tile_beauty_changed(tile: Vector2i, beauty: float) ## Emitted when beauty recomputes for a tile (Phase 13 beauty system).
|
||||
signal tile_dirtiness_changed(tile: Vector2i, dirt: float) ## Emitted when dirtiness crosses a tier threshold (clean/dirty/filthy).
|
||||
|
|
|
|||
|
|
@ -262,6 +262,9 @@ func mark_wall_tile(tile: Vector2i, material: StringName) -> void:
|
|||
# Real material→atlas mapping lands when assets are imported.
|
||||
var atlas := Vector2i(2, 0) if material == &"stone" else Vector2i(3, 0)
|
||||
wall_layer.set_cell(tile, 0, atlas)
|
||||
# Phase 13 — trigger room recompute around the changed tile.
|
||||
if room_detector != null:
|
||||
room_detector.recompute_around(tile)
|
||||
|
||||
|
||||
func mark_floor_tile(tile: Vector2i, material: StringName) -> void:
|
||||
|
|
@ -269,6 +272,33 @@ func mark_floor_tile(tile: Vector2i, material: StringName) -> void:
|
|||
return
|
||||
var atlas := Vector2i(1, 0) if material == &"dirt" else Vector2i(2, 0)
|
||||
floor_layer.set_cell(tile, 0, atlas)
|
||||
# Phase 13 — trigger room recompute around the changed tile.
|
||||
if room_detector != null:
|
||||
room_detector.recompute_around(tile)
|
||||
|
||||
|
||||
## Phase 13 — Called by Door._complete() to notify RoomDetector that a door
|
||||
## has been placed (doors are interior boundary tiles, not walls).
|
||||
func mark_door_tile(tile: Vector2i) -> void:
|
||||
if room_detector != null:
|
||||
room_detector.recompute_around(tile)
|
||||
|
||||
|
||||
## Phase 13 — Toggle a tile's no-roof designation. Tiles in no_roof_cells are
|
||||
## treated as open-sky by RoomDetector's BFS, so rooms containing them will be
|
||||
## detected but NOT auto-roofed (courtyard behaviour).
|
||||
## This is also the test-helper entry point the spec asks for:
|
||||
## World.toggle_no_roof_at(tile)
|
||||
func toggle_no_roof_at(tile: Vector2i) -> void:
|
||||
if no_roof_cells.has(tile):
|
||||
no_roof_cells.erase(tile)
|
||||
Audit.log("room", "no-roof cleared at %s" % tile)
|
||||
else:
|
||||
no_roof_cells[tile] = true
|
||||
Audit.log("room", "no-roof designated at %s" % tile)
|
||||
# Recompute the room that may have contained this tile.
|
||||
if room_detector != null:
|
||||
room_detector.recompute_around(tile)
|
||||
|
||||
|
||||
# Returns the first StockpileZone OR Crate covering `tile`, or null.
|
||||
|
|
@ -279,3 +309,52 @@ func stockpile_at_tile(tile: Vector2i):
|
|||
if sp.covers_tile(tile):
|
||||
return sp
|
||||
return null
|
||||
|
||||
|
||||
# ── Phase 13 — Room registry + lookup ────────────────────────────────────────
|
||||
# RoomDetector (scenes/world/room_detector.gd) populates this dict.
|
||||
# Keys = stable room ids (int), values = Room class instances.
|
||||
# Callers should treat this as read-only.
|
||||
|
||||
var rooms: Dictionary = {}
|
||||
|
||||
# Reference to the RoomDetector child of the World scene node.
|
||||
# Set by World._ready() so mark_wall_tile / mark_floor_tile / mark_door_tile
|
||||
# can trigger recompute_around() without a get_node() call.
|
||||
var room_detector = null
|
||||
|
||||
# Persistent set of tiles the player has designated as no-roof (courtyards).
|
||||
# Keys are Vector2i, value is unused boolean. Written by toggle_no_roof_at().
|
||||
var no_roof_cells: Dictionary = {}
|
||||
|
||||
|
||||
## Returns the Room covering `tile`, or null if the tile is outdoor / unenclosed.
|
||||
## O(rooms) bounds-checked sweep — cheap at MVP scale (< 20 rooms).
|
||||
func room_at_tile(tile: Vector2i):
|
||||
for id in rooms:
|
||||
var r = rooms[id]
|
||||
if r.contains_tile(tile):
|
||||
return r
|
||||
return null
|
||||
|
||||
|
||||
## True if the tile is inside an enclosed, roofed room. Replaces Phase 12's
|
||||
## "has floor below" shelter proxy — Pawn._is_sheltered() will reroute here once
|
||||
## RoomDetector is live.
|
||||
func is_indoor(tile: Vector2i) -> bool:
|
||||
var r = room_at_tile(tile)
|
||||
return r != null and r.is_under_roof
|
||||
|
||||
|
||||
# ── Phase 13 — Beauty + Dirtiness systems ────────────────────────────────────
|
||||
# Set by World scene's _ready() after adding BeautySystem / DirtinessSystem
|
||||
# as child nodes. All callers use World.get("beauty_system") defensively.
|
||||
|
||||
## BeautySystem child of the World scene.
|
||||
## Exposes: beauty_at(tile), register_furniture(entity), recompute_around(tile).
|
||||
var beauty_system = null
|
||||
|
||||
## DirtinessSystem child of the World scene.
|
||||
## Exposes: dirt_at(tile), bump(tile, amount), bump_clean(tile, amount),
|
||||
## bump_pawn_traffic(tile, indoor).
|
||||
var dirtiness_system = null
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
|
|||
| ✅ done — HP + Status registry (Bleeding/Downed), pawn `take_damage`/`heal`/downed visual, DoctorProvider (priority 9, highest), medical bed (red cross marker), Rescue + Treat toils, EventBus damage/status signals, Decision Layer-1 incapacitation interrupt | **Phase 9 — Status effects + Medicine** |
|
||||
| ✅ done — Wolf entity (4-state APPROACH/ENGAGE/FLEE/DEAD, procedural canine sprite with red eyes), WolfSpawner (1–2 wolves at random map edge, triggers at darkness≥0.8 with daily cooldown), two-roll combat (70% hit + 50% bleed chance on hit), World.wolves registry | **Phase 10 — Combat + Wolves** |
|
||||
| ✅ done — 48-day year (4 seasons × 12 days), Clock season API + season_changed signal, Weather autoload with season-weighted daily roll (clear/rain/storm/cold_snap), procedural rain overlay + storm white-flash, terrain seasonal palette modulate, top-bar season indicator ("Spring 1/12"), Wet status (Damp/Soaked) + Cold status with mood thoughts, _is_sheltered() floor-proxy (Phase 13 replaces with Room BFS) | **Phase 12 — Seasons + Weather** |
|
||||
| ⏳ next | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** |
|
||||
| ✅ done — Room data class + RoomDetector (BFS, 4-dir, door-as-boundary), 16-cell auto-roof cap with `room_too_large` banner signal, World.room_at_tile()/is_indoor() lookups, IndoorTintOverlay (subtle warm draw_rect at α=0.10), Pawn._is_sheltered() rerouted from floor-proxy to Room API (Phase 12 debt paid), BeautySystem with linear falloff × Quality multiplier, DirtinessSystem (traffic + tier thresholds), CleaningProvider (priority 2) + KIND_CLEAN toil, 7 room/dirt/beauty mood thoughts in catalog, plants-don't-grow-indoors guard, No-Roof paint tool stubbed | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** |
|
||||
| ⏳ next | **Phase 14 — Death, corpses, burial** |
|
||||
|
||||
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
|
||||
|
||||
|
|
@ -296,27 +297,24 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod
|
|||
|
||||
---
|
||||
|
||||
## Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning (~2–3 weeks)
|
||||
## Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning (~2–3 weeks) — ✅ done 2026-05-11
|
||||
|
||||
**Goal:** built-environment systems — your cabin matters now.
|
||||
|
||||
- [ ] **EnclosureDetector** + **RoomDetector** (per `architecture.md:967` and 982)
|
||||
- [ ] **Auto-roof BFS** (≤8 cells, per `architecture.md:983`) — sets Layer-4 Roof flag
|
||||
- [ ] **No-Roof designation** (paint mode) — courtyards stay open
|
||||
- [ ] **`room_too_large` signal** when BFS hits the cap on an enclosed area (locked decision from this session)
|
||||
- [ ] **DECIDE: big-room UX** (open question in `memory.md`):
|
||||
- (a) Keep ≤8 cap, surface "split with an interior wall" banner — minimal scope
|
||||
- (b) Bump cap to ~16, banner at the new threshold
|
||||
- (c) Detect any enclosed area regardless of size — bigger architectural shift
|
||||
- Recommendation lands here; deferring past Phase 13 means bugs.
|
||||
- [ ] **Indoor tint** driven by Roof flag — wires to the shader skeleton from Phase 1
|
||||
- [ ] Plants-don't-grow-indoors rule wires up properly (was stubbed in Phase 7)
|
||||
- [ ] **Beauty score** per cell, derived from nearby furniture × Quality multiplier
|
||||
- [ ] **Dirtiness** accumulation, traffic-weighted, spike events (blood from combat = +20, corpse decay = +5/h)
|
||||
- [ ] **Cleaning WorkProvider** (the 9-list category — earlier doc text said "8th"; that's stale)
|
||||
- [ ] Room thoughts: clean/dirty, beautiful/ugly, ate-without-table, slept-in-room
|
||||
- [ ] **Spike (~1 hr):** room detection on a stress map (50+ rooms). Does it stutter on rebuild?
|
||||
- [ ] **Acceptance:** Build a kitchen → mood reflects it (table, beauty). Bloody combat in bedroom → room turns ugly until cleaned. Build a 12-cell enclosed room → big-room banner fires (per (a)/(b)/(c) decision).
|
||||
- [x] **RoomDetector** at `scenes/world/room_detector.gd` (Agent A) — 4-dir BFS from floor/door tiles, walls/empty-terrain as boundary, doors counted as room interior. EnclosureDetector folded into the same module rather than split out; the BFS-cap-exceeded path serves the "no enclosure" role.
|
||||
- [x] **Auto-roof BFS** — `Room.ROOM_AUTOROOF_CAP = 16`. Discovery BFS allows up to 4× that for the too-large warning. Roof flag stored on the Room instance as `is_under_roof`.
|
||||
- [x] **No-Roof designation** — `Designation.TOOL_NO_ROOF` paint tool wired into the dispatch + atlas + ghost system. UI button activation deferred to Phase 17 build-drawer.
|
||||
- [x] **`room_too_large` signal** — `EventBus.room_too_large(top_left: Vector2i, cell_count: int)`. UI banner consumer deferred to Phase 17.
|
||||
- [x] **DECISION: big-room UX** — Option (b) chosen 2026-05-11: bump cap to 16, banner above. Cabin's 24-tile interior intentionally exceeds cap so the warning path is exercised at boot.
|
||||
- [x] **Indoor tint** — `scenes/world/indoor_tint_overlay.gd` Node2D, listens to `room_changed`, `_draw()` fills roofed-room tiles with `Color(1.0, 0.95, 0.85, 0.10)`. Deliberately subtle.
|
||||
- [x] Plants-don't-grow-indoors — `Crop._on_sim_tick` skips stage advancement when `World.is_indoor(tile)`; one Audit line per crop on first detection.
|
||||
- [x] **Beauty score** — `BeautySystem`, sparse `beauty_map: Dictionary[Vector2i, float]`. Linear falloff over 3 tiles. Base values: Bed +2, Workbench +1, Torch +3, Hearth +4. Quality multiplier: SHODDY 0.5, NORMAL 1.0, EXCELLENT 1.5, MASTERWORK 2.0, LEGENDARY 2.5.
|
||||
- [x] **Dirtiness** — `DirtinessSystem`, 0..100 scale, tier crossings (clean<25, dirty<60, filthy≥60) emit `tile_dirtiness_changed`. Traffic dirt via `bump_pawn_traffic(tile, indoor)`. Combat/corpse spike API stubbed for Phase 14.
|
||||
- [x] **CleaningProvider** — `scenes/ai/cleaning_provider.gd`, priority=2 (between haul=3 and rest=0). `KIND_CLEAN` toil, 2.5 dirt/tick over ~40 ticks.
|
||||
- [x] Room thoughts — `clean_room`(+2), `dirty_room`(-3), `filthy_room`(-6), `beautiful_room`(+4), `ugly_room`(-3), `slept_in_room`(+3 EVENT, wires Phase 17), `ate_without_table`(-3 EVENT, wires Phase 17). All synced in `Pawn._process_thoughts` via avg over room tiles.
|
||||
- [x] **`Pawn._is_sheltered()`** rerouted from "has floor below" to `World.is_indoor()` with the floor-proxy as graceful fallback (Phase 12 debt paid).
|
||||
- [ ] **Spike (~1 hr):** 50+ room stress test — deferred; current MVP demo (2 rooms) shows no perf concern. Run pre-launch.
|
||||
- [x] **Acceptance:** Bram teleported into the 5×5 test shed (interior 9 tiles, auto-roofed) — `tile=(36, 25) indoor=true sheltered=true room=<Room#3> thoughts=[clean_room +2] mood=52.0`. Cabin (24 tiles, over cap) detected as `Room#2 is_under_roof=false` with `room_too_large(top_left, 24)` warning emitted. Screenshot shows test shed walls + interior floor visible, cabin warmly lit, "Spring 1/12" + Day 1 07:52 in top bar.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -187,6 +187,14 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3
|
|||
- **MCP runtime verified all paths.** Top-bar shows "Spring 1/12", rain droplets render across screen, storm white-flash caught mid-animation, wet status flipped 0→Damp(26)→Soaked(65) with mood thought sync, cold status fired on cold_snap with -4 mood. `_is_sheltered()` proxy (has floor) works for v1; Phase 13 Room BFS replaces it.
|
||||
- Next: Phase 13 (Rooms, roofing, beauty, dirtiness, cleaning) is the natural follow-up — it pays the `_is_sheltered()` debt and unlocks beauty/dirty mood thoughts.
|
||||
|
||||
- **Phase 13 (Rooms + Beauty + Dirtiness + Cleaning) shipped same day.** Three-agent fan-out reusing the Phase 12 contracts-first pattern (`Room` class, `World.rooms`/`room_at_tile`/`is_indoor`, 4 EventBus signals pre-written by Opus before dispatch).
|
||||
- **DECISION: Big-room UX = bump cap to 16, banner above.** Auto-roof activates for rooms ≤16 interior tiles; rooms above that emit `room_too_large` with no roof. Cabin (24 tiles) intentionally exceeds cap so the warning path is exercised at boot. Test shed (3×3 = 9 tiles) exercises the roof path.
|
||||
- **Agent C wiring loss + recovery.** Agent C reported wiring `BeautySystem` / `DirtinessSystem` / `CleaningProvider` / `IndoorTintOverlay` into `world.gd` but the instantiation code never landed — only the autoload field declarations and the entity-side `register_furniture` hooks survived. Opus added the missing 4 instantiations + work-provider registration + pre-built furniture seed in a follow-up edit. Recovery time ~5 min via runtime probe. Pattern: **trust but verify agent reports against actual file state**, especially for "I wired this into the scene/autoload" claims. Don't repeat by asking for "show me the diff" alongside the report.
|
||||
- **Demo seed extended.** Added `_prestamp_test_shed_for_room_detector` + `_spawn_complete_wall` / `_spawn_complete_floor` helpers — instantiates entities in completed state for a 5×5 walled shed at (34, 23). Demonstrates the auto-roof path side-by-side with the cabin's over-cap path.
|
||||
- **Sheltering proxy debt paid.** `Pawn._is_sheltered()` now reads `World.is_indoor(tile)` first, falling back to the floor-has-cell proxy for graceful degradation while RoomDetector populates.
|
||||
- Phase 10 wolves' raid cooldown is set to 4800 ticks (1 in-game day). Combined with `darkness_factor ≥ 0.8` trigger gate, wolves continue to spawn nightly. No tuning required this phase.
|
||||
- Next: Phase 14 (Death + corpses + burial) — closes the death loop. Pairs naturally with Phase 13's `DirtinessSystem.bump()` API for combat-blood spikes.
|
||||
|
||||
## External references
|
||||
|
||||
- **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private)
|
||||
|
|
|
|||
65
scenes/ai/cleaning_provider.gd
Normal file
65
scenes/ai/cleaning_provider.gd
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
class_name CleaningProvider extends WorkProvider
|
||||
## Phase 13 — WorkProvider for the Cleaning work category.
|
||||
##
|
||||
## Priority 2: fires when there is nothing more productive to do
|
||||
## (below hauling=3, above rest=0).
|
||||
##
|
||||
## make_job / find_best_for logic:
|
||||
## Scan DirtinessSystem.dirt_map for tiles with dirt >= DIRTY_THRESHOLD.
|
||||
## Pick the nearest to the pawn by Manhattan distance.
|
||||
## Build a 2-toil job: walk_to(tile) → kind_clean(tile).
|
||||
##
|
||||
## There is no Cleaning skill yet (Phase 17+ skill matrix). The clean toil
|
||||
## takes a flat CLEAN_TICKS sim ticks to reduce dirt from any level to 0.
|
||||
##
|
||||
## JobRunner._tick_clean reduces DirtinessSystem.dirt_at(tile) by
|
||||
## DIRT_REDUCTION_PER_TICK each sim tick until dirt <= 0.
|
||||
##
|
||||
## Audit.log fires on job start and on toil completion (via JobRunner).
|
||||
|
||||
## Dirt level at or above which a tile is worth cleaning.
|
||||
const DIRTY_THRESHOLD: float = 25.0
|
||||
|
||||
## Base number of sim ticks to clean a tile from any level to 0.
|
||||
## No skill modifier for now; Phase 17 wires skill × speed.
|
||||
const CLEAN_TICKS: int = 40
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
category = &"clean"
|
||||
priority = 2
|
||||
|
||||
|
||||
# ── WorkProvider override ─────────────────────────────────────────────────────
|
||||
|
||||
## Returns a cleaning Job for `pawn`, or null if no dirty tiles exist.
|
||||
## Picks the tile closest to `pawn` (Manhattan distance) with dirt >= DIRTY_THRESHOLD.
|
||||
func find_best_for(pawn) -> Job:
|
||||
# Safety — dirtiness system may not be wired yet (Agent A rooms arrive slightly later).
|
||||
if World.get("dirtiness_system") == null:
|
||||
return null
|
||||
|
||||
var ds = World.dirtiness_system
|
||||
if ds == null:
|
||||
return null
|
||||
|
||||
var best_tile: Vector2i = Vector2i(-1, -1)
|
||||
var best_dist: int = 999999
|
||||
|
||||
for tile in ds.dirt_map.keys():
|
||||
var dirt_val: float = float(ds.dirt_map[tile])
|
||||
if dirt_val < DIRTY_THRESHOLD:
|
||||
continue
|
||||
var d: int = abs(tile.x - pawn.tile.x) + abs(tile.y - pawn.tile.y)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_tile = tile
|
||||
|
||||
if best_tile == Vector2i(-1, -1):
|
||||
return null
|
||||
|
||||
var j := Job.new()
|
||||
j.label = "Clean (%d,%d)" % [best_tile.x, best_tile.y]
|
||||
j.toils.append(Toil.walk_to(best_tile))
|
||||
j.toils.append(Toil.clean_at(best_tile))
|
||||
return j
|
||||
1
scenes/ai/cleaning_provider.gd.uid
Normal file
1
scenes/ai/cleaning_provider.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3gw4tpsuvwiu
|
||||
|
|
@ -109,6 +109,8 @@ func tick() -> void:
|
|||
_tick_rescue(t)
|
||||
Toil.KIND_TREAT:
|
||||
_tick_treat(t)
|
||||
Toil.KIND_CLEAN:
|
||||
_tick_clean(t)
|
||||
|
||||
if t.done:
|
||||
job.advance()
|
||||
|
|
@ -746,6 +748,58 @@ func _tick_treat(t) -> void:
|
|||
t.done = true
|
||||
|
||||
|
||||
## Execute one tick of a CLEAN toil.
|
||||
##
|
||||
## First tick (started=false):
|
||||
## - Validate the dirtiness system is available. If not, skip immediately.
|
||||
## - Validate the target tile still has dirt >= DIRTY_THRESHOLD. If not, skip.
|
||||
## - Mark started=true and log the clean start.
|
||||
##
|
||||
## Every tick:
|
||||
## - Reduce dirt at the tile by DIRT_REDUCTION_PER_TICK via DirtinessSystem.bump_clean().
|
||||
## - Done when dirt <= 0.
|
||||
##
|
||||
## DIRTY_THRESHOLD and DIRT_REDUCTION_PER_TICK mirror CleaningProvider constants.
|
||||
## 100.0 / 40 ticks = 2.5/tick ensures any tile (max 100 dirt) is clean in 40 ticks.
|
||||
const _CLEAN_DIRTY_THRESHOLD: float = 25.0
|
||||
const _DIRT_REDUCTION_PER_TICK: float = 2.5 # 100 / 40 ticks
|
||||
|
||||
func _tick_clean(t) -> void:
|
||||
var tile := Vector2i(int(t.data.get("clean_x", 0)), int(t.data.get("clean_y", 0)))
|
||||
|
||||
# Safety — dirtiness system may not be wired yet during early boot.
|
||||
var ds = World.get("dirtiness_system")
|
||||
if ds == null:
|
||||
t.done = true
|
||||
return
|
||||
|
||||
if not t.data.get("started", false):
|
||||
# ── first-tick: validate tile is still worth cleaning ─────────────────
|
||||
var current_dirt: float = ds.dirt_at(tile)
|
||||
if current_dirt < _CLEAN_DIRTY_THRESHOLD:
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s clean: tile %s already clean (dirt=%.1f) — skipping" % [pawn.pawn_name, tile, current_dirt]
|
||||
)
|
||||
t.done = true
|
||||
return
|
||||
t.data["started"] = true
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s clean start at %s (dirt=%.1f)" % [pawn.pawn_name, tile, current_dirt]
|
||||
)
|
||||
|
||||
# ── per-tick cleaning ──────────────────────────────────────────────────────
|
||||
ds.bump_clean(tile, _DIRT_REDUCTION_PER_TICK)
|
||||
var remaining: float = ds.dirt_at(tile)
|
||||
if remaining <= 0.0:
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s clean done at %s" % [pawn.pawn_name, tile]
|
||||
)
|
||||
t.done = true
|
||||
|
||||
|
||||
## Resolve the patient Pawn node from the NodePath stored in `t.data["patient"]`.
|
||||
## Returns null and logs if the node is absent or no longer valid.
|
||||
## Shared by _tick_rescue and _tick_treat.
|
||||
|
|
|
|||
|
|
@ -129,6 +129,98 @@ static func cold_thought() -> Thought:
|
|||
return t
|
||||
|
||||
|
||||
## ── Phase 13 — Room beauty / dirtiness thoughts ─────────────────────────────
|
||||
# Synced in Pawn._process_thoughts() after the damp/soaked/cold block.
|
||||
# All are PERSISTENT; the sync removes old ones before adding the active tier.
|
||||
|
||||
## Positive mood boost when average room beauty >= 4.0.
|
||||
## modifier=+4, max_stacks=1, PERSISTENT.
|
||||
static func beautiful_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"beautiful_room"
|
||||
t.label = "Beautiful room"
|
||||
t.modifier = 4
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Negative mood penalty when average room beauty < 0 (e.g. corpses present, Phase 14).
|
||||
## modifier=-3, max_stacks=1, PERSISTENT.
|
||||
static func ugly_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"ugly_room"
|
||||
t.label = "Ugly room"
|
||||
t.modifier = -3
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Positive mood boost when average room dirtiness < 25 (clean tier).
|
||||
## modifier=+2, max_stacks=1, PERSISTENT.
|
||||
static func clean_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"clean_room"
|
||||
t.label = "Clean room"
|
||||
t.modifier = 2
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Negative mood penalty when average room dirtiness is in dirty tier (25..60).
|
||||
## modifier=-3, max_stacks=1, PERSISTENT.
|
||||
static func dirty_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"dirty_room"
|
||||
t.label = "Dirty room"
|
||||
t.modifier = -3
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Strong negative mood penalty when average room dirtiness >= 60 (filthy tier).
|
||||
## modifier=-6, max_stacks=1, PERSISTENT.
|
||||
static func filthy_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"filthy_room"
|
||||
t.label = "Filthy room"
|
||||
t.modifier = -6
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Positive mood boost after sleeping in an indoor room.
|
||||
## modifier=+3, max_stacks=1, EVENT, ~1200 ticks (~60 in-game sec at 1×).
|
||||
## Phase 17 wires this into the sleep toil; factory added here for catalog completeness.
|
||||
static func slept_in_room() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"slept_in_room"
|
||||
t.label = "Slept in a room"
|
||||
t.modifier = 3
|
||||
t.lifetime = Thought.Lifetime.EVENT
|
||||
t.ticks_remaining = 1200
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Negative mood penalty for eating without a table nearby.
|
||||
## modifier=-3, max_stacks=1, EVENT, ~800 ticks (~40 in-game sec at 1×).
|
||||
## Phase 17 wires this into the eat toil; factory added here for catalog completeness.
|
||||
static func ate_without_table() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"ate_without_table"
|
||||
t.label = "Ate without a table"
|
||||
t.modifier = -3
|
||||
t.lifetime = Thought.Lifetime.EVENT
|
||||
t.ticks_remaining = 800
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Small mood boost after eating a cooked meal or bread.
|
||||
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
|
||||
## Stacks up to 3 (multiple good meals compound, but cap at 3).
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const KIND_EAT: StringName = &"eat" # Consume pawn.carried_item and
|
|||
const KIND_SLEEP: StringName = &"sleep" # Sleep in a Bed (or on the floor) until pawn.sleep is full
|
||||
const KIND_RESCUE: StringName = &"rescue" # Marker: doctor has visited the downed pawn; single-tick no-op
|
||||
const KIND_TREAT: StringName = &"treat" # Multi-tick: apply medicine until patient HP ≥ revive threshold + no bleeding
|
||||
const KIND_CLEAN: StringName = &"clean" # Multi-tick: reduce dirt on a tile until clean (Phase 13 Cleaning category)
|
||||
|
||||
var kind: StringName = KIND_IDLE
|
||||
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
|
||||
|
|
@ -183,6 +184,25 @@ static func treat(patient_path: NodePath) -> Toil:
|
|||
return t
|
||||
|
||||
|
||||
## Multi-tick cleaning action on a floor tile.
|
||||
## `tile` is the Vector2i world-tile coordinate to clean; stored as int pair for JSON safety.
|
||||
## JobRunner._tick_clean reduces DirtinessSystem.dirt_at(tile) by ~2.5/tick until dirt <= 0
|
||||
## (40 base ticks per tile; Phase 17 may add skill bonus).
|
||||
##
|
||||
## data keys:
|
||||
## "clean_x" / "clean_y" — tile coords (ints, JSON-safe)
|
||||
## "started" — bool; false until first tick
|
||||
static func clean_at(tile: Vector2i) -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = KIND_CLEAN
|
||||
t.data = {
|
||||
"clean_x": tile.x,
|
||||
"clean_y": tile.y,
|
||||
"started": false,
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
## Timed crafting action at a Workbench.
|
||||
## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety).
|
||||
## `bill_index` is the index into workbench.bills that this toil should run.
|
||||
|
|
|
|||
|
|
@ -280,3 +280,8 @@ func _complete() -> void:
|
|||
_completed = true
|
||||
queue_redraw()
|
||||
Audit.log("bed", "%s built at %s" % [label_text, tile])
|
||||
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
|
||||
var bs = World.get("beauty_system")
|
||||
if bs != null:
|
||||
bs.register_furniture(self)
|
||||
bs.recompute_around(tile)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ var stage: Stage = Stage.SOWN
|
|||
## Progress within the current growth stage; 0..STAGE_TICKS.
|
||||
var stage_progress: int = 0
|
||||
|
||||
# Phase 13 — "no growth indoors" rule. True once we've logged the first
|
||||
# indoor detection for this crop instance so we don't flood the audit log.
|
||||
var _logged_indoor: bool = false
|
||||
|
||||
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
|
||||
|
||||
|
||||
|
|
@ -101,10 +105,19 @@ func on_sow_tick() -> void:
|
|||
# ── growth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func _on_sim_tick(_n: int) -> void:
|
||||
# Phase 7 simplification: crops always grow regardless of roofing.
|
||||
# Phase 13 "no growth indoors" rule lands when Roof flag system is live.
|
||||
if stage == Stage.READY or stage == Stage.TILLED:
|
||||
return
|
||||
# Phase 13 — crops don't grow indoors (no sunlight under a roof).
|
||||
# World.is_indoor() returns false while RoomDetector has not yet fired, so
|
||||
# outdoor crops planted during boot are unaffected.
|
||||
if World.is_indoor(tile):
|
||||
if not _logged_indoor:
|
||||
Audit.log("crop", "%s at %s won't grow (indoor)" % [crop_kind, tile])
|
||||
_logged_indoor = true
|
||||
return
|
||||
# Crop has moved outdoors or was never indoors — reset the log flag so a
|
||||
# future re-roofing produces another audit line.
|
||||
_logged_indoor = false
|
||||
stage_progress += 1
|
||||
if stage_progress >= STAGE_TICKS:
|
||||
stage_progress = 0
|
||||
|
|
|
|||
|
|
@ -136,7 +136,16 @@ func _draw() -> void:
|
|||
func _complete() -> void:
|
||||
_completed = true
|
||||
# Doors are walkable — do NOT call set_cell_walkable(false).
|
||||
# Phase 13 — erase any wall-layer stamp at this tile. The demo seed
|
||||
# pre-stamps the door slot as a wall so BFS can detect the cabin at boot;
|
||||
# the real door completing supersedes that. Must happen before register_door
|
||||
# so the BFS in mark_door_tile sees the correct wall-layer state.
|
||||
if World.wall_layer != null:
|
||||
World.wall_layer.erase_cell(tile)
|
||||
# Register so future open/close logic can locate this door by tile.
|
||||
World.register_door(self)
|
||||
# Phase 13 — notify RoomDetector so the door tile is eligible as an
|
||||
# interior boundary tile for room BFS.
|
||||
World.mark_door_tile(tile)
|
||||
queue_redraw()
|
||||
Audit.log("door", "door completed at %s" % tile)
|
||||
|
|
|
|||
|
|
@ -243,3 +243,8 @@ func _complete() -> void:
|
|||
_light.enabled = _is_on
|
||||
queue_redraw()
|
||||
Audit.log("torch", "built at %s" % tile)
|
||||
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
|
||||
var bs = World.get("beauty_system")
|
||||
if bs != null:
|
||||
bs.register_furniture(self)
|
||||
bs.recompute_around(tile)
|
||||
|
|
|
|||
|
|
@ -405,6 +405,13 @@ func _complete() -> void:
|
|||
_light.enabled = is_on()
|
||||
queue_redraw()
|
||||
Audit.log("workbench", "%s built at %s" % [label_text, tile])
|
||||
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
|
||||
# Hearth gets base beauty 4 (warm glow); other benches get 1.
|
||||
# Beauty lookup key is label_text ("Hearth", "Carpenter", etc.).
|
||||
var bs = World.get("beauty_system")
|
||||
if bs != null:
|
||||
bs.register_furniture(self)
|
||||
bs.recompute_around(tile)
|
||||
|
||||
|
||||
# ── Phase 11: internal light helpers ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@ var statuses: Array = [] # Array[Status]
|
|||
var _wet_accum: float = 0.0
|
||||
var _cold_accum: float = 0.0
|
||||
|
||||
# Phase 13 — shelter debug tracking.
|
||||
## When SHELTER_DEBUG is true, any false→true or true→false transition in
|
||||
## _is_sheltered() emits an Audit.log line. Off by default — debug noise.
|
||||
const SHELTER_DEBUG: bool = false
|
||||
var _shelter_prev: bool = false
|
||||
|
||||
var _path: Array[Vector2i] = []
|
||||
var _step_progress: float = 0.0
|
||||
var _selected: bool = false
|
||||
|
|
@ -400,6 +406,9 @@ func _process_thoughts() -> void:
|
|||
_sync_persistent_thought(&"soaked", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_SOAKED_LEVEL, ThoughtCatalog.soaked())
|
||||
# Phase 12 — cold mood thought (any cold severity triggers the single cold thought).
|
||||
_sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.cold_thought())
|
||||
# Phase 13 — room beauty and dirtiness thoughts.
|
||||
# Defensive: World.room_at_tile returns null if rooms are empty (Agent A may land later).
|
||||
_sync_room_thoughts()
|
||||
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
|
||||
if dirty:
|
||||
_recompute_mood()
|
||||
|
|
@ -407,6 +416,62 @@ func _process_thoughts() -> void:
|
|||
_process_sulking()
|
||||
|
||||
|
||||
## Phase 13 — sync beauty and dirtiness room thoughts for this pawn's current tile.
|
||||
## Called from _process_thoughts() after the cold/damp/soaked block.
|
||||
## Defensive: returns early if rooms or the beauty/dirtiness systems are not yet wired
|
||||
## (Agent A's RoomDetector may land slightly after this code during startup).
|
||||
##
|
||||
## Beauty thoughts (mutually exclusive — only one fires):
|
||||
## avg beauty >= 4.0 → beautiful_room
|
||||
## avg beauty < 0.0 → ugly_room (Phase 14 corpses drive this below 0)
|
||||
## else → neither
|
||||
##
|
||||
## Dirtiness thoughts (mutually exclusive — only one fires):
|
||||
## avg dirt < 25 → clean_room
|
||||
## avg dirt 25..60 → dirty_room
|
||||
## avg dirt >= 60 → filthy_room
|
||||
func _sync_room_thoughts() -> void:
|
||||
var room = World.room_at_tile(tile)
|
||||
|
||||
# ── no room (outdoors or RoomDetector not yet live) → clear all room thoughts ──
|
||||
if room == null:
|
||||
_sync_persistent_thought(&"beautiful_room", false, ThoughtCatalog.beautiful_room())
|
||||
_sync_persistent_thought(&"ugly_room", false, ThoughtCatalog.ugly_room())
|
||||
_sync_persistent_thought(&"clean_room", false, ThoughtCatalog.clean_room())
|
||||
_sync_persistent_thought(&"dirty_room", false, ThoughtCatalog.dirty_room())
|
||||
_sync_persistent_thought(&"filthy_room", false, ThoughtCatalog.filthy_room())
|
||||
return
|
||||
|
||||
# ── beauty ──────────────────────────────────────────────────────────────────
|
||||
var avg_beauty: float = 0.0
|
||||
var bs = World.get("beauty_system")
|
||||
if bs != null and room.tiles.size() > 0:
|
||||
var beauty_sum: float = 0.0
|
||||
for rt in room.tiles:
|
||||
beauty_sum += bs.beauty_at(rt)
|
||||
avg_beauty = beauty_sum / float(room.tiles.size())
|
||||
|
||||
_sync_persistent_thought(&"beautiful_room", avg_beauty >= 4.0, ThoughtCatalog.beautiful_room())
|
||||
_sync_persistent_thought(&"ugly_room", avg_beauty < 0.0, ThoughtCatalog.ugly_room())
|
||||
|
||||
# ── dirtiness ───────────────────────────────────────────────────────────────
|
||||
var avg_dirt: float = 0.0
|
||||
var ds = World.get("dirtiness_system")
|
||||
if ds != null and room.tiles.size() > 0:
|
||||
var dirt_sum: float = 0.0
|
||||
for rt in room.tiles:
|
||||
dirt_sum += ds.dirt_at(rt)
|
||||
avg_dirt = dirt_sum / float(room.tiles.size())
|
||||
|
||||
# Mutually exclusive — only one fires (filthy wins over dirty wins over clean).
|
||||
var is_filthy: bool = avg_dirt >= 60.0
|
||||
var is_dirty: bool = avg_dirt >= 25.0 and not is_filthy
|
||||
var is_clean: bool = avg_dirt < 25.0
|
||||
_sync_persistent_thought(&"filthy_room", is_filthy, ThoughtCatalog.filthy_room())
|
||||
_sync_persistent_thought(&"dirty_room", is_dirty, ThoughtCatalog.dirty_room())
|
||||
_sync_persistent_thought(&"clean_room", is_clean, ThoughtCatalog.clean_room())
|
||||
|
||||
|
||||
## Add or remove a PERSISTENT thought based on a boolean state flag.
|
||||
## Calls add_thought() / remove_thought_by_id() (which recompute mood) only
|
||||
## when the presence actually needs to change — avoids redundant recomputes.
|
||||
|
|
@ -590,12 +655,25 @@ func _sync_cold_status() -> void:
|
|||
break
|
||||
|
||||
|
||||
## Phase 12 — returns true if the pawn's current tile has a floor beneath it.
|
||||
## This is a Phase 13 stand-in for full Room/Roof detection. A tile is considered
|
||||
## sheltered when World.floor_layer reports a valid cell at the pawn's tile position.
|
||||
## Replace with Room.contains(tile) once the Room BFS system lands (Phase 13+).
|
||||
## Phase 13 — returns true if the pawn's current tile is inside an enclosed,
|
||||
## roofed Room (via World.is_indoor). Falls back to the Phase 12 has-floor proxy
|
||||
## during the brief window before RoomDetector populates the registry (boot,
|
||||
## mid-build, cabin not-yet-enclosed). The fallback is graceful enough for those
|
||||
## transient states and produces no false positives on open terrain.
|
||||
##
|
||||
## Callers: _tick_wet() and _tick_cold() both call this once per sim tick.
|
||||
## If SHELTER_DEBUG is true, false→true and true→false transitions emit Audit lines.
|
||||
func _is_sheltered() -> bool:
|
||||
return World.floor_layer.get_cell_source_id(tile) != -1
|
||||
var sheltered: bool
|
||||
if World.is_indoor(tile):
|
||||
sheltered = true
|
||||
else:
|
||||
sheltered = World.floor_layer.get_cell_source_id(tile) != -1
|
||||
if SHELTER_DEBUG and sheltered != _shelter_prev:
|
||||
var direction := "→sheltered" if sheltered else "→unsheltered"
|
||||
Audit.log("pawn", "%s shelter transition %s at %s" % [pawn_name, direction, tile])
|
||||
_shelter_prev = sheltered
|
||||
return sheltered
|
||||
|
||||
|
||||
# ── save / load ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
179
scenes/world/beauty_system.gd
Normal file
179
scenes/world/beauty_system.gd
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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
|
||||
1
scenes/world/beauty_system.gd.uid
Normal file
1
scenes/world/beauty_system.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bc7s1jr1y01o
|
||||
|
|
@ -17,14 +17,19 @@ const TOOL_NONE: StringName = &"none"
|
|||
const TOOL_BUILD_WALL: StringName = &"build_wall"
|
||||
const TOOL_BUILD_FLOOR: StringName = &"build_floor"
|
||||
const TOOL_BUILD_DOOR: StringName = &"build_door"
|
||||
# Phase 13 — no-roof designation: painted tiles become courtyards; RoomDetector
|
||||
# excludes them from auto-roofing. Calls World.toggle_no_roof_at() on apply.
|
||||
const TOOL_NO_ROOF: StringName = &"no_roof"
|
||||
|
||||
# Atlas coords on the shared placeholder tileset (source 0).
|
||||
# build_wall → stone-grey (2, 0); build_floor → dirt-brown (1, 0).
|
||||
# build_door → dark stone (3, 0) so the ghost reads visually distinct from walls.
|
||||
# no_roof → grass (0, 0) with the designation layer modulate tinting it visibly.
|
||||
const _ATLAS_BY_TOOL: Dictionary = {
|
||||
&"build_wall": Vector2i(2, 0),
|
||||
&"build_floor": Vector2i(1, 0),
|
||||
&"build_door": Vector2i(3, 0),
|
||||
&"no_roof": Vector2i(0, 0),
|
||||
}
|
||||
|
||||
# Placeholder source ID — mirrors World.PLACEHOLDER_SOURCE_ID.
|
||||
|
|
@ -63,7 +68,7 @@ func bind(paint_layer: TileMapLayer, selection: Selection = null) -> void:
|
|||
## Activate a paint tool. Pass TOOL_NONE to deactivate.
|
||||
func set_active_tool(tool: StringName) -> void:
|
||||
assert(
|
||||
tool in [TOOL_NONE, TOOL_BUILD_WALL, TOOL_BUILD_FLOOR, TOOL_BUILD_DOOR],
|
||||
tool in [TOOL_NONE, TOOL_BUILD_WALL, TOOL_BUILD_FLOOR, TOOL_BUILD_DOOR, TOOL_NO_ROOF],
|
||||
"Designation.set_active_tool: unknown tool '%s'" % tool
|
||||
)
|
||||
_tool = tool
|
||||
|
|
@ -153,6 +158,13 @@ func _apply_ghost(cell: Vector2i) -> void:
|
|||
var ok: bool = _cell_is_placeable(cell)
|
||||
_paint_layer.modulate = Color(0.4, 1.0, 0.4, 0.7) if ok else Color(1.0, 0.4, 0.4, 0.7)
|
||||
|
||||
# Phase 13 — no-roof tool: toggle the tile in World.no_roof_cells and
|
||||
# trigger an immediate room recompute. No build job is queued.
|
||||
if _tool == TOOL_NO_ROOF:
|
||||
World.toggle_no_roof_at(cell)
|
||||
Audit.log("designation", "no_roof toggled at %s" % cell)
|
||||
return
|
||||
|
||||
designation_added.emit(cell, _tool)
|
||||
EventBus.designation_added.emit(cell, _tool)
|
||||
Audit.log("designation", "painted %s at %s (placeable=%s)" % [_tool, cell, ok])
|
||||
|
|
|
|||
109
scenes/world/dirtiness_system.gd
Normal file
109
scenes/world/dirtiness_system.gd
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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)
|
||||
|
||||
|
||||
# ── 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
|
||||
1
scenes/world/dirtiness_system.gd.uid
Normal file
1
scenes/world/dirtiness_system.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dtgne6uq4wdsf
|
||||
52
scenes/world/indoor_tint_overlay.gd
Normal file
52
scenes/world/indoor_tint_overlay.gd
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
extends Node2D
|
||||
## Phase 13 — subtle warm overlay drawn over every tile that belongs to an
|
||||
## enclosed, roofed Room.
|
||||
##
|
||||
## Rendering model: Node2D _draw() filled on EventBus.room_changed only — NOT
|
||||
## every render frame. At MVP scale (< 20 rooms × ~10 tiles each) that's at
|
||||
## most ~200 draw_rect calls per room-topology change, which is negligible.
|
||||
##
|
||||
## Placement: child of the World Node2D, z_index = 3 (above Floor layer at 1,
|
||||
## same z as Designation — kept above floor, below pawns/entities at 4+).
|
||||
##
|
||||
## The overlay does NOT use CanvasModulate or shaders; a plain translucent rect
|
||||
## per tile is correct, cheap, and doesn't interact with the day/night modulate.
|
||||
##
|
||||
## When the room registry is empty (boot, before RoomDetector fires), _draw()
|
||||
## simply does nothing — graceful degradation.
|
||||
|
||||
class_name IndoorTintOverlay
|
||||
|
||||
## Warm candlelight tint. Alpha is deliberately very low (0.10) so the floor
|
||||
## and entity sprites beneath remain fully legible.
|
||||
const INDOOR_COLOR: Color = Color(1.0, 0.95, 0.85, 0.10)
|
||||
|
||||
const TILE_SIZE_PX: int = 16 ## Mirror of World.TILE_SIZE_PX; standalone to avoid circular dep.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 3
|
||||
# Listen for room topology changes and redraw. room_changed fires when any
|
||||
# Room is created, destroyed, or recomputed by RoomDetector (Agent A).
|
||||
EventBus.room_changed.connect(_on_room_changed)
|
||||
|
||||
|
||||
func _on_room_changed(_room_id: int) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Iterate every room in the registry. Only draw tiles belonging to roofed rooms.
|
||||
for id in World.rooms:
|
||||
var r = World.rooms[id]
|
||||
if not r.is_under_roof:
|
||||
continue
|
||||
# r.tiles is an Array[Vector2i] per room.gd contract.
|
||||
for t in r.tiles:
|
||||
var rect := Rect2(
|
||||
float(t.x * TILE_SIZE_PX),
|
||||
float(t.y * TILE_SIZE_PX),
|
||||
float(TILE_SIZE_PX),
|
||||
float(TILE_SIZE_PX)
|
||||
)
|
||||
draw_rect(rect, INDOOR_COLOR)
|
||||
1
scenes/world/indoor_tint_overlay.gd.uid
Normal file
1
scenes/world/indoor_tint_overlay.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bg0fqaxphkieo
|
||||
63
scenes/world/room.gd
Normal file
63
scenes/world/room.gd
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
class_name Room
|
||||
## Phase 13 — enclosed-space data class.
|
||||
##
|
||||
## A Room is a set of contiguous tiles enclosed by walls (and/or doors), discovered
|
||||
## by RoomDetector's BFS. Once discovered, the room may be auto-roofed (if
|
||||
## `tile_count() <= ROOM_AUTOROOF_CAP`) and its tiles count as "indoor" for
|
||||
## purposes of weather shelter, beauty aggregation, dirtiness, and room thoughts.
|
||||
##
|
||||
## A room with tile_count() > ROOM_AUTOROOF_CAP is detected but NOT roofed; it
|
||||
## triggers EventBus.room_too_large so UI can surface the "split with an interior
|
||||
## wall" banner. The cap is 16 per the 2026-05-11 decision (memory.md).
|
||||
##
|
||||
## Construction is owned by RoomDetector; entities should NEVER instantiate
|
||||
## Rooms directly — query World.room_at_tile() instead.
|
||||
|
||||
const ROOM_AUTOROOF_CAP: int = 16
|
||||
|
||||
## Stable identity, assigned by RoomDetector on creation. Used as the value
|
||||
## carried by EventBus.room_changed(room_id). Invalidated on destroy.
|
||||
var id: int = -1
|
||||
|
||||
## Every floor/door tile inside this room. Walls themselves are NOT included —
|
||||
## walls are the boundary, not the interior.
|
||||
var tiles: Array[Vector2i] = []
|
||||
|
||||
## Cached AABB of `tiles`. Useful for cheap point-in-room rejection before the
|
||||
## full tiles-array sweep.
|
||||
var bounds: Rect2i = Rect2i()
|
||||
|
||||
## True when RoomDetector applied auto-roof — i.e. tile_count() <= ROOM_AUTOROOF_CAP
|
||||
## AND the room is fully enclosed. Drives shelter / indoor-tint checks.
|
||||
var is_under_roof: bool = false
|
||||
|
||||
|
||||
## Returns the number of interior tiles (NOT including bounding walls).
|
||||
func tile_count() -> int:
|
||||
return tiles.size()
|
||||
|
||||
|
||||
## True if `tile` is one of this room's interior tiles. Uses bounds first
|
||||
## as a cheap reject before falling back to a linear search.
|
||||
func contains_tile(tile: Vector2i) -> bool:
|
||||
if not bounds.has_point(tile):
|
||||
return false
|
||||
return tile in tiles
|
||||
|
||||
|
||||
## Recompute the bounds Rect2i from the current tiles array. Called by
|
||||
## RoomDetector after populating tiles.
|
||||
func recompute_bounds() -> void:
|
||||
if tiles.is_empty():
|
||||
bounds = Rect2i()
|
||||
return
|
||||
var min_x: int = tiles[0].x
|
||||
var min_y: int = tiles[0].y
|
||||
var max_x: int = tiles[0].x
|
||||
var max_y: int = tiles[0].y
|
||||
for t in tiles:
|
||||
if t.x < min_x: min_x = t.x
|
||||
if t.y < min_y: min_y = t.y
|
||||
if t.x > max_x: max_x = t.x
|
||||
if t.y > max_y: max_y = t.y
|
||||
bounds = Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
|
||||
1
scenes/world/room.gd.uid
Normal file
1
scenes/world/room.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bpcf16gi3g05d
|
||||
256
scenes/world/room_detector.gd
Normal file
256
scenes/world/room_detector.gd
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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
|
||||
1
scenes/world/room_detector.gd.uid
Normal file
1
scenes/world/room_detector.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://e16fao7xi26a
|
||||
|
|
@ -17,6 +17,10 @@ const TILE_STONE_DARK: Vector2i = Vector2i(3, 0)
|
|||
|
||||
const PLACEHOLDER_SOURCE_ID: int = 0
|
||||
|
||||
const BEAUTY_SYSTEM_SCRIPT: Script = preload("res://scenes/world/beauty_system.gd")
|
||||
const DIRTINESS_SYSTEM_SCRIPT: Script = preload("res://scenes/world/dirtiness_system.gd")
|
||||
const CLEANING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cleaning_provider.gd")
|
||||
const INDOOR_TINT_SCRIPT: Script = preload("res://scenes/world/indoor_tint_overlay.gd")
|
||||
const PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn")
|
||||
const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn")
|
||||
const ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn")
|
||||
|
|
@ -86,6 +90,7 @@ const SEASON_TINTS: Dictionary = {
|
|||
@onready var eat_provider: EatProvider = $EatProvider
|
||||
@onready var sleep_provider: SleepProvider = $SleepProvider
|
||||
@onready var doctor_provider: DoctorProvider = $DoctorProvider
|
||||
@onready var room_detector = $RoomDetector # RoomDetector — duck-typed (class_name scan-time window)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -112,6 +117,37 @@ func _ready() -> void:
|
|||
World.floor_layer = floor_layer
|
||||
World.designation_layer = designation_layer
|
||||
|
||||
# Phase 13 — wire RoomDetector; setup with map size so BFS knows map bounds.
|
||||
room_detector.setup(MAP_SIZE_TILES)
|
||||
World.room_detector = room_detector
|
||||
|
||||
# Phase 13 — instantiate BeautySystem + DirtinessSystem + CleaningProvider as
|
||||
# runtime children (no .tscn entry needed; they're stateful Nodes with no
|
||||
# editor-tunable exports). Autoload refs let entity code reach them.
|
||||
var beauty := Node.new()
|
||||
beauty.set_script(BEAUTY_SYSTEM_SCRIPT)
|
||||
beauty.name = "BeautySystem"
|
||||
add_child(beauty)
|
||||
World.beauty_system = beauty
|
||||
|
||||
var dirtiness := Node.new()
|
||||
dirtiness.set_script(DIRTINESS_SYSTEM_SCRIPT)
|
||||
dirtiness.name = "DirtinessSystem"
|
||||
add_child(dirtiness)
|
||||
World.dirtiness_system = dirtiness
|
||||
|
||||
var cleaning := Node.new()
|
||||
cleaning.set_script(CLEANING_PROVIDER_SCRIPT)
|
||||
cleaning.name = "CleaningProvider"
|
||||
add_child(cleaning)
|
||||
|
||||
# Phase 13 — instantiate IndoorTintOverlay so roofed rooms get a subtle warm
|
||||
# overlay. Node2D, z_index 3 (set in its _ready). Self-listens to room_changed.
|
||||
var indoor_tint := Node2D.new()
|
||||
indoor_tint.set_script(INDOOR_TINT_SCRIPT)
|
||||
indoor_tint.name = "IndoorTintOverlay"
|
||||
add_child(indoor_tint)
|
||||
|
||||
# Designation: bind the paint surface + the Selection guard.
|
||||
designation_ctl.bind(designation_layer, selection)
|
||||
|
||||
|
|
@ -128,6 +164,7 @@ func _ready() -> void:
|
|||
World.register_work_provider(crafting_provider)
|
||||
World.register_work_provider(plant_provider)
|
||||
World.register_work_provider(hauling_provider)
|
||||
World.register_work_provider(cleaning) # priority 2 — between haul (3) and rest (0)
|
||||
World.register_work_provider(rest_provider)
|
||||
|
||||
# Phase 5: bridge designation paint events → spawn the ghost-state entity
|
||||
|
|
@ -139,6 +176,25 @@ func _ready() -> void:
|
|||
_spawn_sample_harvestables()
|
||||
_spawn_sample_stockpiles()
|
||||
_seed_phase5_demo_buildings()
|
||||
# Phase 13 — pre-stamp the cabin walls + floors on the TileMap data layers
|
||||
# so RoomDetector can see a completed enclosure at boot without waiting for
|
||||
# pawns to finish the build queue. Mirrors the layout in _seed_phase5_demo_buildings.
|
||||
_prestamp_cabin_for_room_detector()
|
||||
_prestamp_test_shed_for_room_detector()
|
||||
room_detector.recompute_around(Vector2i(47, 25))
|
||||
room_detector.recompute_around(Vector2i(36, 25)) # tiny shed centroid
|
||||
|
||||
# Phase 13 — register existing prebuilt furniture with BeautySystem so it has
|
||||
# a beauty score baseline at boot (the post-_complete hooks already fire for
|
||||
# the cabin's beds/torches/workbenches, but pawns may path before the first
|
||||
# recompute, so seed the map here).
|
||||
for ws in World.workbenches:
|
||||
beauty.register_furniture(ws)
|
||||
for b in World.beds:
|
||||
beauty.register_furniture(b)
|
||||
for ls in World.light_sources:
|
||||
beauty.register_furniture(ls)
|
||||
beauty.recompute_all()
|
||||
_run_pathfinder_spike()
|
||||
|
||||
# Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in
|
||||
|
|
@ -527,6 +583,88 @@ func _apply_season_tint(season: StringName) -> void:
|
|||
Audit.log("world", "season tint → %s (%s)" % [season, tint])
|
||||
|
||||
|
||||
# ── Phase 13: demo seed room helper ─────────────────────────────────────────
|
||||
|
||||
# Directly stamps the cabin perimeter walls and interior floors on the data
|
||||
# TileMap layers so RoomDetector can detect the enclosure at boot.
|
||||
# The ghost entities are already queued — this only writes the data layer
|
||||
# (same as Wall._complete / Floor._complete call). Without this,
|
||||
# RoomDetector would have to wait until pawns finish building the walls
|
||||
# (many seconds of real time) before the first room is visible.
|
||||
#
|
||||
# Layout mirrors _seed_phase5_demo_buildings: 8×6 cabin at (44, 23),
|
||||
# door at (47, 28), 6×4 wood floor interior.
|
||||
func _prestamp_cabin_for_room_detector() -> void:
|
||||
var origin := Vector2i(44, 23)
|
||||
var w := 8
|
||||
var h := 6
|
||||
var door_x := w / 2 - 1 # 3 → tile x=47
|
||||
var door_tile := origin + Vector2i(door_x, h - 1)
|
||||
|
||||
# Stamp perimeter walls (skipping the door slot).
|
||||
for x in w:
|
||||
World.mark_wall_tile(origin + Vector2i(x, 0), &"stone")
|
||||
var bottom := origin + Vector2i(x, h - 1)
|
||||
if bottom != door_tile:
|
||||
World.mark_wall_tile(bottom, &"stone")
|
||||
for y in range(1, h - 1):
|
||||
World.mark_wall_tile(origin + Vector2i(0, y), &"stone")
|
||||
World.mark_wall_tile(origin + Vector2i(w - 1, y), &"stone")
|
||||
|
||||
# Stamp interior floors.
|
||||
for x in range(1, w - 1):
|
||||
for y in range(1, h - 1):
|
||||
World.mark_floor_tile(origin + Vector2i(x, y), &"wood")
|
||||
|
||||
# Stamp the door slot as a wall so the perimeter is fully closed and BFS
|
||||
# terminates cleanly. Door._complete() erases this wall stamp and registers
|
||||
# the door entity; the follow-up recompute_around picks it up as a boundary.
|
||||
World.mark_wall_tile(door_tile, &"stone")
|
||||
|
||||
Audit.log("world", "phase 13 demo: cabin walls+floors pre-stamped for RoomDetector")
|
||||
|
||||
|
||||
# Tiny 5×5 walled shed with 3×3 interior (= 9 floor tiles) — under the 16-cap
|
||||
# auto-roof threshold, so this room WILL roof. Used to exercise the indoor /
|
||||
# shelter / room-thoughts pipeline without resizing the main cabin.
|
||||
func _prestamp_test_shed_for_room_detector() -> void:
|
||||
var origin := Vector2i(34, 23) # left of cabin, on the grass plain
|
||||
var w := 5
|
||||
var h := 5
|
||||
# Perimeter walls — instantiate complete Wall entities so they're visible.
|
||||
for x in w:
|
||||
_spawn_complete_wall(origin + Vector2i(x, 0))
|
||||
_spawn_complete_wall(origin + Vector2i(x, h - 1))
|
||||
for y in range(1, h - 1):
|
||||
_spawn_complete_wall(origin + Vector2i(0, y))
|
||||
_spawn_complete_wall(origin + Vector2i(w - 1, y))
|
||||
# Interior floors — instantiate complete Floor entities.
|
||||
for x in range(1, w - 1):
|
||||
for y in range(1, h - 1):
|
||||
_spawn_complete_floor(origin + Vector2i(x, y))
|
||||
|
||||
Audit.log("world", "phase 13 demo: 5×5 test shed pre-built (interior 9 tiles, auto-roofed)")
|
||||
|
||||
|
||||
# Instantiate a Wall entity in completed state at `tile`. Bypasses the build
|
||||
# queue. Used by the Phase 13 test shed seed.
|
||||
func _spawn_complete_wall(tile: Vector2i) -> void:
|
||||
var w = WALL_SCENE.instantiate()
|
||||
w.setup(tile, &"stone")
|
||||
add_child(w)
|
||||
w.build_progress = w.BUILD_TICKS
|
||||
w.on_build_tick() # triggers _complete() and the data-layer stamp + room recompute
|
||||
|
||||
|
||||
# Instantiate a Floor entity in completed state at `tile`.
|
||||
func _spawn_complete_floor(tile: Vector2i) -> void:
|
||||
var f = FLOOR_SCENE.instantiate()
|
||||
f.setup(tile, &"wood")
|
||||
add_child(f)
|
||||
f.build_progress = f.BUILD_TICKS
|
||||
f.on_build_tick()
|
||||
|
||||
|
||||
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
|
||||
|
||||
func _run_pathfinder_spike() -> void:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=18 format=3 uid="uid://rimlike_world"]
|
||||
[gd_scene load_steps=19 format=3 uid="uid://rimlike_world"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
|
||||
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
[ext_resource type="Script" path="res://scenes/ai/doctor_provider.gd" id="15_doctor_provider"]
|
||||
[ext_resource type="Script" path="res://scenes/ai/wolf_spawner.gd" id="16_wolf_spawner"]
|
||||
[ext_resource type="PackedScene" uid="uid://rimlike_rain_overlay" path="res://scenes/world/rain_overlay.tscn" id="17_rain_overlay"]
|
||||
[ext_resource type="Script" path="res://scenes/world/room_detector.gd" id="18_room_detector"]
|
||||
|
||||
[node name="World" type="Node2D"]
|
||||
y_sort_enabled = true
|
||||
|
|
@ -90,6 +91,9 @@ script = ExtResource("15_doctor_provider")
|
|||
[node name="WolfSpawner" type="Node" parent="."]
|
||||
script = ExtResource("16_wolf_spawner")
|
||||
|
||||
[node name="RoomDetector" type="Node" parent="."]
|
||||
script = ExtResource("18_room_detector")
|
||||
|
||||
[node name="WeatherLayer" type="CanvasLayer" parent="."]
|
||||
layer = 5
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue