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.
|
# Phase 12 — Seasons + Weather.
|
||||||
signal season_changed(season: StringName) ## Emitted by Clock when current_season() rolls over (Spring → Summer → Autumn → Winter).
|
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.
|
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.
|
# Real material→atlas mapping lands when assets are imported.
|
||||||
var atlas := Vector2i(2, 0) if material == &"stone" else Vector2i(3, 0)
|
var atlas := Vector2i(2, 0) if material == &"stone" else Vector2i(3, 0)
|
||||||
wall_layer.set_cell(tile, 0, atlas)
|
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:
|
func mark_floor_tile(tile: Vector2i, material: StringName) -> void:
|
||||||
|
|
@ -269,6 +272,33 @@ func mark_floor_tile(tile: Vector2i, material: StringName) -> void:
|
||||||
return
|
return
|
||||||
var atlas := Vector2i(1, 0) if material == &"dirt" else Vector2i(2, 0)
|
var atlas := Vector2i(1, 0) if material == &"dirt" else Vector2i(2, 0)
|
||||||
floor_layer.set_cell(tile, 0, atlas)
|
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.
|
# 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):
|
if sp.covers_tile(tile):
|
||||||
return sp
|
return sp
|
||||||
return null
|
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 — 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 — 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** |
|
| ✅ 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.
|
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.
|
**Goal:** built-environment systems — your cabin matters now.
|
||||||
|
|
||||||
- [ ] **EnclosureDetector** + **RoomDetector** (per `architecture.md:967` and 982)
|
- [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.
|
||||||
- [ ] **Auto-roof BFS** (≤8 cells, per `architecture.md:983`) — sets Layer-4 Roof flag
|
- [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`.
|
||||||
- [ ] **No-Roof designation** (paint mode) — courtyards stay open
|
- [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.
|
||||||
- [ ] **`room_too_large` signal** when BFS hits the cap on an enclosed area (locked decision from this session)
|
- [x] **`room_too_large` signal** — `EventBus.room_too_large(top_left: Vector2i, cell_count: int)`. UI banner consumer deferred to Phase 17.
|
||||||
- [ ] **DECIDE: big-room UX** (open question in `memory.md`):
|
- [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.
|
||||||
- (a) Keep ≤8 cap, surface "split with an interior wall" banner — minimal scope
|
- [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.
|
||||||
- (b) Bump cap to ~16, banner at the new threshold
|
- [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.
|
||||||
- (c) Detect any enclosed area regardless of size — bigger architectural shift
|
- [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.
|
||||||
- Recommendation lands here; deferring past Phase 13 means bugs.
|
- [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.
|
||||||
- [ ] **Indoor tint** driven by Roof flag — wires to the shader skeleton from Phase 1
|
- [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.
|
||||||
- [ ] Plants-don't-grow-indoors rule wires up properly (was stubbed in Phase 7)
|
- [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.
|
||||||
- [ ] **Beauty score** per cell, derived from nearby furniture × Quality multiplier
|
- [x] **`Pawn._is_sheltered()`** rerouted from "has floor below" to `World.is_indoor()` with the floor-proxy as graceful fallback (Phase 12 debt paid).
|
||||||
- [ ] **Dirtiness** accumulation, traffic-weighted, spike events (blood from combat = +20, corpse decay = +5/h)
|
- [ ] **Spike (~1 hr):** 50+ room stress test — deferred; current MVP demo (2 rooms) shows no perf concern. Run pre-launch.
|
||||||
- [ ] **Cleaning WorkProvider** (the 9-list category — earlier doc text said "8th"; that's stale)
|
- [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.
|
||||||
- [ ] 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- 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
|
## External references
|
||||||
|
|
||||||
- **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private)
|
- **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)
|
_tick_rescue(t)
|
||||||
Toil.KIND_TREAT:
|
Toil.KIND_TREAT:
|
||||||
_tick_treat(t)
|
_tick_treat(t)
|
||||||
|
Toil.KIND_CLEAN:
|
||||||
|
_tick_clean(t)
|
||||||
|
|
||||||
if t.done:
|
if t.done:
|
||||||
job.advance()
|
job.advance()
|
||||||
|
|
@ -746,6 +748,58 @@ func _tick_treat(t) -> void:
|
||||||
t.done = true
|
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"]`.
|
## 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.
|
## Returns null and logs if the node is absent or no longer valid.
|
||||||
## Shared by _tick_rescue and _tick_treat.
|
## Shared by _tick_rescue and _tick_treat.
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,98 @@ static func cold_thought() -> Thought:
|
||||||
return t
|
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.
|
## Small mood boost after eating a cooked meal or bread.
|
||||||
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_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).
|
## 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_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_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_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
|
var kind: StringName = KIND_IDLE
|
||||||
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
|
## 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
|
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.
|
## Timed crafting action at a Workbench.
|
||||||
## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety).
|
## `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.
|
## `bill_index` is the index into workbench.bills that this toil should run.
|
||||||
|
|
|
||||||
|
|
@ -280,3 +280,8 @@ func _complete() -> void:
|
||||||
_completed = true
|
_completed = true
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
Audit.log("bed", "%s built at %s" % [label_text, tile])
|
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.
|
## Progress within the current growth stage; 0..STAGE_TICKS.
|
||||||
var stage_progress: int = 0
|
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")
|
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,10 +105,19 @@ func on_sow_tick() -> void:
|
||||||
# ── growth ────────────────────────────────────────────────────────────────────
|
# ── growth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_sim_tick(_n: int) -> void:
|
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:
|
if stage == Stage.READY or stage == Stage.TILLED:
|
||||||
return
|
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
|
stage_progress += 1
|
||||||
if stage_progress >= STAGE_TICKS:
|
if stage_progress >= STAGE_TICKS:
|
||||||
stage_progress = 0
|
stage_progress = 0
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,16 @@ func _draw() -> void:
|
||||||
func _complete() -> void:
|
func _complete() -> void:
|
||||||
_completed = true
|
_completed = true
|
||||||
# Doors are walkable — do NOT call set_cell_walkable(false).
|
# 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.
|
# Register so future open/close logic can locate this door by tile.
|
||||||
World.register_door(self)
|
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()
|
queue_redraw()
|
||||||
Audit.log("door", "door completed at %s" % tile)
|
Audit.log("door", "door completed at %s" % tile)
|
||||||
|
|
|
||||||
|
|
@ -243,3 +243,8 @@ func _complete() -> void:
|
||||||
_light.enabled = _is_on
|
_light.enabled = _is_on
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
Audit.log("torch", "built at %s" % tile)
|
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()
|
_light.enabled = is_on()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
Audit.log("workbench", "%s built at %s" % [label_text, tile])
|
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 ─────────────────────────────────────────
|
# ── Phase 11: internal light helpers ─────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,12 @@ var statuses: Array = [] # Array[Status]
|
||||||
var _wet_accum: float = 0.0
|
var _wet_accum: float = 0.0
|
||||||
var _cold_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 _path: Array[Vector2i] = []
|
||||||
var _step_progress: float = 0.0
|
var _step_progress: float = 0.0
|
||||||
var _selected: bool = false
|
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())
|
_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).
|
# Phase 12 — cold mood thought (any cold severity triggers the single cold thought).
|
||||||
_sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.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).
|
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
|
||||||
if dirty:
|
if dirty:
|
||||||
_recompute_mood()
|
_recompute_mood()
|
||||||
|
|
@ -407,6 +416,62 @@ func _process_thoughts() -> void:
|
||||||
_process_sulking()
|
_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.
|
## Add or remove a PERSISTENT thought based on a boolean state flag.
|
||||||
## Calls add_thought() / remove_thought_by_id() (which recompute mood) only
|
## Calls add_thought() / remove_thought_by_id() (which recompute mood) only
|
||||||
## when the presence actually needs to change — avoids redundant recomputes.
|
## when the presence actually needs to change — avoids redundant recomputes.
|
||||||
|
|
@ -590,12 +655,25 @@ func _sync_cold_status() -> void:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
## Phase 12 — returns true if the pawn's current tile has a floor beneath it.
|
## Phase 13 — returns true if the pawn's current tile is inside an enclosed,
|
||||||
## This is a Phase 13 stand-in for full Room/Roof detection. A tile is considered
|
## roofed Room (via World.is_indoor). Falls back to the Phase 12 has-floor proxy
|
||||||
## sheltered when World.floor_layer reports a valid cell at the pawn's tile position.
|
## during the brief window before RoomDetector populates the registry (boot,
|
||||||
## Replace with Room.contains(tile) once the Room BFS system lands (Phase 13+).
|
## 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:
|
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 ─────────────────────────────────────────────────────────────
|
# ── 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_WALL: StringName = &"build_wall"
|
||||||
const TOOL_BUILD_FLOOR: StringName = &"build_floor"
|
const TOOL_BUILD_FLOOR: StringName = &"build_floor"
|
||||||
const TOOL_BUILD_DOOR: StringName = &"build_door"
|
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).
|
# Atlas coords on the shared placeholder tileset (source 0).
|
||||||
# build_wall → stone-grey (2, 0); build_floor → dirt-brown (1, 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.
|
# 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 = {
|
const _ATLAS_BY_TOOL: Dictionary = {
|
||||||
&"build_wall": Vector2i(2, 0),
|
&"build_wall": Vector2i(2, 0),
|
||||||
&"build_floor": Vector2i(1, 0),
|
&"build_floor": Vector2i(1, 0),
|
||||||
&"build_door": Vector2i(3, 0),
|
&"build_door": Vector2i(3, 0),
|
||||||
|
&"no_roof": Vector2i(0, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Placeholder source ID — mirrors World.PLACEHOLDER_SOURCE_ID.
|
# 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.
|
## Activate a paint tool. Pass TOOL_NONE to deactivate.
|
||||||
func set_active_tool(tool: StringName) -> void:
|
func set_active_tool(tool: StringName) -> void:
|
||||||
assert(
|
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
|
"Designation.set_active_tool: unknown tool '%s'" % tool
|
||||||
)
|
)
|
||||||
_tool = tool
|
_tool = tool
|
||||||
|
|
@ -153,6 +158,13 @@ func _apply_ghost(cell: Vector2i) -> void:
|
||||||
var ok: bool = _cell_is_placeable(cell)
|
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)
|
_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)
|
designation_added.emit(cell, _tool)
|
||||||
EventBus.designation_added.emit(cell, _tool)
|
EventBus.designation_added.emit(cell, _tool)
|
||||||
Audit.log("designation", "painted %s at %s (placeable=%s)" % [_tool, cell, ok])
|
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 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 PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn")
|
||||||
const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn")
|
const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn")
|
||||||
const ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.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 eat_provider: EatProvider = $EatProvider
|
||||||
@onready var sleep_provider: SleepProvider = $SleepProvider
|
@onready var sleep_provider: SleepProvider = $SleepProvider
|
||||||
@onready var doctor_provider: DoctorProvider = $DoctorProvider
|
@onready var doctor_provider: DoctorProvider = $DoctorProvider
|
||||||
|
@onready var room_detector = $RoomDetector # RoomDetector — duck-typed (class_name scan-time window)
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -112,6 +117,37 @@ func _ready() -> void:
|
||||||
World.floor_layer = floor_layer
|
World.floor_layer = floor_layer
|
||||||
World.designation_layer = designation_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: bind the paint surface + the Selection guard.
|
||||||
designation_ctl.bind(designation_layer, selection)
|
designation_ctl.bind(designation_layer, selection)
|
||||||
|
|
||||||
|
|
@ -128,6 +164,7 @@ func _ready() -> void:
|
||||||
World.register_work_provider(crafting_provider)
|
World.register_work_provider(crafting_provider)
|
||||||
World.register_work_provider(plant_provider)
|
World.register_work_provider(plant_provider)
|
||||||
World.register_work_provider(hauling_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)
|
World.register_work_provider(rest_provider)
|
||||||
|
|
||||||
# Phase 5: bridge designation paint events → spawn the ghost-state entity
|
# Phase 5: bridge designation paint events → spawn the ghost-state entity
|
||||||
|
|
@ -139,6 +176,25 @@ func _ready() -> void:
|
||||||
_spawn_sample_harvestables()
|
_spawn_sample_harvestables()
|
||||||
_spawn_sample_stockpiles()
|
_spawn_sample_stockpiles()
|
||||||
_seed_phase5_demo_buildings()
|
_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()
|
_run_pathfinder_spike()
|
||||||
|
|
||||||
# Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in
|
# 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])
|
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² ──────────────────────────────────
|
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
|
||||||
|
|
||||||
func _run_pathfinder_spike() -> void:
|
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="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"]
|
[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/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="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="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"]
|
[node name="World" type="Node2D"]
|
||||||
y_sort_enabled = true
|
y_sort_enabled = true
|
||||||
|
|
@ -90,6 +91,9 @@ script = ExtResource("15_doctor_provider")
|
||||||
[node name="WolfSpawner" type="Node" parent="."]
|
[node name="WolfSpawner" type="Node" parent="."]
|
||||||
script = ExtResource("16_wolf_spawner")
|
script = ExtResource("16_wolf_spawner")
|
||||||
|
|
||||||
|
[node name="RoomDetector" type="Node" parent="."]
|
||||||
|
script = ExtResource("18_room_detector")
|
||||||
|
|
||||||
[node name="WeatherLayer" type="CanvasLayer" parent="."]
|
[node name="WeatherLayer" type="CanvasLayer" parent="."]
|
||||||
layer = 5
|
layer = 5
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue