Resolves the rimlike docs audit. Decisions made this session: - Map: 40² → 80² for MVP slice; architecture sized to ~120² ceiling. Pawn count unchanged (3 start / 6 cap) — 'split-the-difference' sizing rather than frontier-feel scale. - World-view camera (locked): pinch + drag-pan + double-tap-centre. Storyteller alerts include 'Go there' tap. No minimap, no follow-cam in MVP. New ui.md section captures this. - Storyteller cooldowns: per-event AND per-category — both gates must pass. Resolves design.md/architecture.md disagreement. - MAX_STACKS_PER_THOUGHT = 5 (was named-but-unset). - Hauling no-destination fallback: drop after 3 retry passes, surface a passive 'No stockpile accepts X' alert. - Container all-neighbors-blocked: hold then drop after ~5 sim s to avoid deadlock. - Auto-roof big-room UX: emits room_too_large signal when BFS hits the ≤8-cell cap; UI surfaces a 'split with an interior wall' banner. Threshold + exact wording added to memory.md TODOs. Doc rot cleaned in design.md and architecture.md: removed the stale '8 work categories' intermediate sections (canonical 9-list lives later in each file). Updated architecture.md perf claims for the new map size. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1097 lines
45 KiB
Markdown
1097 lines
45 KiB
Markdown
# rimlike — architecture
|
||
|
||
> Companion to [`memory.md`](../memory.md). This file is the **technical architecture**: pawn AI, job system, time/tick model, Godot 4 engine layout. Game design (mechanics, simplifications, vertical slice) lives in [`design.md`](./design.md).
|
||
|
||
## Pawn AI / job system
|
||
|
||
Five-layer design. Slimmer than Rimworld's ThinkTree+JobGiver+WorkGiver+JobDriver, hits ~80% of the behavior. ~800–1500 lines of GDScript for full MVP, single-developer comprehensible.
|
||
|
||
### Layers (top wins)
|
||
|
||
| # | Layer | Role |
|
||
|---|---|---|
|
||
| 1 | **Decision pipeline** | Priority-ordered "what do I do RIGHT NOW?" walk: Incapacitated → Forced → Critical need → Mental break (v2) → Work → Recreation (v2) → Idle. First match wins. |
|
||
| 2 | **WorkProvider** | One per work category. Scans world for jobs in its domain, returns best for this pawn. Pawn iterates its own priority list calling `find_best_for(self)`. |
|
||
| 3 | **Job + JobRunner** | Job is plain data (type, target, materials, duration, skill). JobRunner is a small state machine running the toils (steps): walk → pick up → walk → work → complete. |
|
||
| 4 | **Status interrupts** | Bleeding/Tired/Hungry/Sick at threshold flag the pawn for re-decision. Each status declares its interrupt urgency (mid-walk vs. between-toils). |
|
||
| 5 | **Player overrides** | Tap-pawn → "do this" issues a forced one-shot job into the pawn's queue. Runs at Layer 1 step 2. |
|
||
|
||
### MVP WorkProviders
|
||
|
||
Construction · Mining · Hauling · Cooking · Plant · Doctor · Combat (later).
|
||
|
||
### Locked design defaults
|
||
|
||
- **Priority levels: 5 (1 Critical / 2 High / 3 Normal / 4 When idle / Off)** — matches Rimworld + Going Medieval contract. Pawn iterates priorities 1→4, scanning display-order categories within each tier.
|
||
- Within-tier order = display order (draggable, default hand-curated).
|
||
- Job claiming: a Job picked up by a pawn is `claimed_by = self`; others ignore. Released on cancel/fail.
|
||
- Single-pawn jobs only in MVP. No co-op hauling/building.
|
||
- Skills modify duration and quality, never permission.
|
||
- Interrupted runners drop carried items at the current cell — visible and recoverable in the world.
|
||
|
||
### Pseudocode shape
|
||
|
||
```gdscript
|
||
# Pawn.gd (excerpt)
|
||
func _on_sim_tick(dt: float) -> void:
|
||
update_statuses(dt) # bleed, hunger, fatigue grow
|
||
|
||
if current_job and should_interrupt_for_status():
|
||
cancel_current_job()
|
||
|
||
if current_job == null:
|
||
current_job = decide_next_job()
|
||
if current_job:
|
||
job_runner = JobRunner.new(self, current_job)
|
||
|
||
if job_runner:
|
||
match job_runner.tick(dt):
|
||
JobResult.COMPLETE, JobResult.FAILED:
|
||
_finish_job()
|
||
|
||
func decide_next_job() -> Job:
|
||
if has_status(Status.DOWNED): return null
|
||
if not forced_queue.is_empty(): return forced_queue.pop_front()
|
||
var critical = check_critical_needs()
|
||
if critical: return critical
|
||
return find_work_by_priority()
|
||
|
||
func find_work_by_priority() -> Job:
|
||
for category in WorkCategories.in_priority_order(work_priorities):
|
||
var job = WorkRegistry.get(category).find_best_for(self)
|
||
if job: return job
|
||
return null
|
||
```
|
||
|
||
### Open AI design questions
|
||
|
||
- Skill depth — committed to **minimal**: 5 skills × 0–10, level by use, multiplicative speed/quality bonus.
|
||
- Walking-speed problem — see Time / tick model below; mostly absorbed by Fast being default.
|
||
- Job chaining (cook = fetch + fire + cook) — one Job with multi-step toils, not nested sub-jobs.
|
||
- Animal AI — separate predatory state machine, not the pawn pipeline.
|
||
- Idle behavior — punt to v2; pawns stand still or wander locally.
|
||
- Save round-trip — JobRunner mid-toil state must serialize cleanly from day one.
|
||
|
||
## Time / tick model
|
||
|
||
The simulation runs on a **fixed-rate sim tick** decoupled from render. Speed multipliers compress wall-clock time by stepping multiple sim ticks per render frame.
|
||
|
||
### Locked numbers
|
||
|
||
| Parameter | Value | Notes |
|
||
|---|---|---|
|
||
| **Sim tick rate** | **20 Hz** (50 ms per tick at 1×) | Smooth enough for animation interp, low enough to be cheap on phone |
|
||
| **Render rate** | 60 Hz, decoupled | Pawn sprites lerp between last and next sim tick positions |
|
||
| **In-game day** | 24 in-game hours | Game uses a 24h clock; storyteller hooks at sunrise/sunset |
|
||
| **1× speed** | 1 in-game minute = 1 real second | 1 day = 24 min real time at 1× — used for combat / fine work |
|
||
| **Fast (default)** | 5× | 1 day ≈ **5 min real time** — matches the 5–15 min session target |
|
||
| **Ultra** | 12× | 1 day ≈ 2 min real time — for skipping quiet stretches |
|
||
| **Pause** | 0× | Time stops; UI fully interactive |
|
||
|
||
### Speed multiplier mechanics
|
||
|
||
Multipliers drive sim tick stepping per render frame. At 60 Hz render with 20 Hz sim:
|
||
|
||
- 1×: render frame queues 1 sim tick every 3 render frames (20 ticks/sec)
|
||
- Fast (5×): render frame queues sim ticks at 100 ticks/sec (~1.67 ticks per render frame)
|
||
- Ultra (12×): 240 ticks/sec (4 ticks per render frame)
|
||
- Pause: zero ticks queued
|
||
|
||
Per-tick budget at Ultra: ~4 ms to stay in 60 fps render. Achievable for our scope (3–6 pawns, < 500 entities, **80×80 MVP map; sized to a 120² ceiling**). At 80² the AStarGrid2D has 6.4k nodes; longest path ≤ 160 tiles; full A* runs sub-millisecond on a mid-range phone. At 120² (22.5k nodes) it's still well under a millisecond per query.
|
||
|
||
### Pawn movement
|
||
|
||
| Speed | Real-time crossing of 80-tile map (corner-to-corner) | Feel |
|
||
|---|---|---|
|
||
| 1× (1 tile / 0.5 s) | ~80 s | Watchable, deliberate |
|
||
| Fast (5×) | ~16 s | Snappy, default cadence |
|
||
| Ultra (12×) | ~7 s | Almost teleporting |
|
||
|
||
Travel times are roughly 2× the original 40-tile estimates. A wolf spawning at the map edge gives ~20 s real-time lead at Fast — meaningful warning, not tedious.
|
||
|
||
This mostly absorbs the "walking-speed problem" surfaced earlier — Fast as default keeps a 5-min day from feeling like watching paint dry.
|
||
|
||
### Auto-pause triggers
|
||
|
||
Default-on; player can disable individually in settings.
|
||
|
||
- New threat appears (wolf seen, raid begins)
|
||
- Pawn becomes Downed
|
||
- Pawn dies
|
||
- Storyteller fires a player-prompt event (modal)
|
||
- (Optional) low health threshold, low food threshold
|
||
|
||
### Suspend / resume
|
||
|
||
- **Save between sim ticks**, never mid-tick. We own the loop.
|
||
- Save snapshot includes: current sim tick number, all entity state, job state (including JobRunner mid-toil), time-of-day, storyteller state.
|
||
- On resume: restore, re-establish render lerp from the last sim tick.
|
||
- **No background simulation.** App backgrounded = sim paused. Avoids "lost colony to a raid while at work" rage and saves battery.
|
||
- On resume from a long absence: brief "you've been away X minutes" toast, then sim resumes from where it stopped. No fast-forward in MVP.
|
||
|
||
### Why 20 Hz, not 10 Hz
|
||
|
||
10 Hz works for the sim itself but creates visible jitter in pawn animation unless render-side interpolation is heavily tuned. 20 Hz halves the lerp distance, halves the perceived snap on direction changes, and the per-tick cost is still trivial. If profiling on a low-end phone shows trouble, 10 Hz with rich interp is the fallback.
|
||
|
||
### Render-vs-sim animation
|
||
|
||
- Sprite/walk animations driven by **render time**, not sim ticks. They look smooth even at low sim Hz.
|
||
- Pawn world-position lerps from `last_sim_tick_pos` to `next_sim_tick_pos` based on `accumulator / tick_duration`.
|
||
- World tile changes (build complete, wall placed) snap on sim tick — no need to lerp tiles.
|
||
|
||
## Storage: zones, containers, hauling
|
||
|
||
For player-facing rules (categories, priorities, stack sizes), see [`design.md`](./design.md). This section is data structures + AI plug-in.
|
||
|
||
### Storage destinations: a unified concept
|
||
|
||
Two things accept items: **floor zones** and **containers**. The hauling AI treats them as a single pool of `StorageDestination` candidates, scored identically. Internally they share a small interface:
|
||
|
||
```gdscript
|
||
interface StorageDestination:
|
||
var filter: int # bitmask of 16 categories
|
||
var priority: int # 0..4 (Low..Critical)
|
||
var name: String
|
||
|
||
func accepts(item: Item) -> bool
|
||
func has_capacity_for(item: Item) -> bool
|
||
func best_drop_position(from: Vector2i) -> Vector2i # tile a pawn drops at
|
||
func deposit(item: Item) -> int # qty actually deposited
|
||
```
|
||
|
||
### StockpileZone
|
||
|
||
```gdscript
|
||
class StockpileZone extends StorageDestination:
|
||
var id: int
|
||
var name: String # "Kitchen", default "Stockpile #N"
|
||
var cells: Array[Vector2i]
|
||
var filter: int # bitmask of 16 categories
|
||
var priority: int # CRITICAL=4, IMPORTANT=3, PREFERRED=2, NORMAL=1, LOW=0
|
||
var contents: Dictionary # cell → ItemStack (one stack per cell, one type per cell)
|
||
```
|
||
|
||
Stored as `World.zones: Array[StockpileZone]`. Rendered as a transparent colored overlay (priority-tinted) **only** when the Zones panel is open. Zero render cost otherwise.
|
||
|
||
### Container
|
||
|
||
A Furniture entity. Built via Build → Furniture → Crate. One generic type in MVP (4-stack capacity).
|
||
|
||
```gdscript
|
||
class Container extends Furniture, StorageDestination:
|
||
var name: String # "Crate", "Crate (kitchen)" etc.
|
||
var position: Vector2i # 1 tile, impassable
|
||
var filter: int # bitmask of 16 categories
|
||
var priority: int # 0..4
|
||
var stacks: Array[ItemStack] # max 4 in MVP
|
||
```
|
||
|
||
Pawns walk to any 4-neighbor of `position` to deposit (`best_drop_position` returns a free neighbor). Container tile itself is non-walkable.
|
||
|
||
Stored as `World.containers: Array[Container]`. Inspect screen accessible by tapping the entity.
|
||
|
||
### HaulingProvider
|
||
|
||
Slots into the existing 5-layer pawn AI as a `WorkProvider`. Considers BOTH zones and containers via the unified `StorageDestination` interface.
|
||
|
||
```gdscript
|
||
# WorkProvider_Hauling.gd
|
||
func find_best_for(pawn: Pawn) -> Job:
|
||
var best: Job = null
|
||
var best_score: float = -INF
|
||
|
||
for item in World.items_needing_haul():
|
||
var target = World.find_best_destination_for(item)
|
||
if target == null: continue
|
||
|
||
var score = priority_delta(item.current_priority, target.priority) * 1000.0 \
|
||
+ completeness_bonus(item, target) \
|
||
- distance(pawn.position, item.position)
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
best = Job.new(JobType.HAUL, item, target, ...)
|
||
|
||
return best
|
||
|
||
func find_best_destination_for(item: Item) -> StorageDestination:
|
||
var candidates: Array[StorageDestination] = []
|
||
for z in World.zones:
|
||
if z.accepts(item) and z.has_capacity_for(item):
|
||
candidates.append(z)
|
||
for c in World.containers:
|
||
if c.accepts(item) and c.has_capacity_for(item):
|
||
candidates.append(c)
|
||
candidates.sort_by(|d| (d.priority * -1000) + distance(item.position, d.best_drop_position(item.position)))
|
||
return candidates[0] if candidates else null
|
||
```
|
||
|
||
**Score components, weighted high → low:**
|
||
|
||
1. **Priority delta** — Low (0) → Critical (4) is the biggest delta. Moving items between equal-priority destinations gives 0 delta and is dropped.
|
||
2. **Completeness bonus** — finishing a partial stack beats opening a new one. Counters the sprawl problem.
|
||
3. **Distance** — tiebreak.
|
||
|
||
### Priority flow (Rimworld semantics)
|
||
|
||
Items in any storage destination at priority `p` may be re-hauled to a destination at priority > `p` if the higher destination has space and accepts the type. This is what enables "items flow Low → Critical."
|
||
|
||
To avoid hot loops:
|
||
|
||
- Newly-placed items go straight to the highest-priority valid destination (no flow needed).
|
||
- Existing items in stored destinations are re-considered every ~5 sim seconds via a periodic dirty-marking pass: `World.mark_low_priority_items_dirty()`.
|
||
- When `World.find_best_destination_for(item)` returns a higher-priority destination than the item's current one, the item joins `_dirty_items` and HaulingProvider picks it up.
|
||
|
||
### items_needing_haul
|
||
|
||
A dirty-set on World, not a full scan:
|
||
|
||
- When an item spawns (workbench drop, designation completes, corpse drops, etc.) → added.
|
||
- When an item enters its highest-priority valid destination → removed.
|
||
- Periodic priority-flow pass (every ~5 sim sec) re-marks items in lower-priority destinations if higher options have opened up.
|
||
|
||
O(dirty × destinations) per tick. Trivial at our scale (worst case ~100 items × ~20 destinations).
|
||
|
||
### Hauling Job toils
|
||
|
||
```
|
||
walk_to(item.cell) → pick_up(item, capped_at_carry_capacity)
|
||
→ walk_to(target.best_drop_position(item.cell)) → deposit(item)
|
||
→ on_complete: update destination contents, mark partial-stack-still-needs-haul if applicable
|
||
```
|
||
|
||
Carry capacity capped at one stack of one type. Multi-type carry is v2.
|
||
|
||
**No-destination fallback.** If `find_best_destination_for(item)` returns null on three consecutive periodic-flow passes (~15 sim seconds), the item is dropped where it lies, taken off the dirty set, and a passive "*No stockpile accepts X*" alert is queued. This prevents `_dirty_items` from cycling forever when the player has zero matching storage. The alert clears the moment a matching destination opens up (it re-enters the dirty set on the next storage-change event).
|
||
|
||
**Container all-neighbors-blocked.** `best_drop_position(from)` returns the closest free 4-neighbor of the container. If all four are blocked, the hauler holds the carry and re-tries on the next pass; if still blocked after ~5 sim seconds the carry gets dropped on the hauler's current tile (same fallback as above) so it doesn't deadlock.
|
||
|
||
### Container build flow
|
||
|
||
1. Player taps Build → Furniture → Crate.
|
||
2. Designation paint mode, but single-tile (each tap places one crate ghost).
|
||
3. Confirm → BuildJob queued, materials cost (e.g. 8 wood per crate).
|
||
4. Construction-priority pawn picks up: haul materials → walk → work N ticks → place Container entity.
|
||
5. New crate defaults: filter = all on, priority = Normal, name = "Crate #N".
|
||
6. Tap to inspect/configure.
|
||
|
||
### Container operations
|
||
|
||
- **Inspect** — opens UI screen (filter chips, priority picker, contents, Empty/Move/Demolish).
|
||
- **Empty** — all stacks become loose Items at the crate's neighbor cells; `_dirty_items` updated.
|
||
- **Move** — designate new position; pawn deconstructs + reconstructs (materials preserved). Or simpler: empty + demolish + rebuild manually. MVP: empty + demolish.
|
||
- **Demolish** — like a wall: contents drop as loose items, entity removed, materials partially refunded.
|
||
|
||
### Workbench output
|
||
|
||
Workbench entity has `output_cell: Vector2i` (one tile in front). Products drop there; haulers clear via the normal pipeline.
|
||
|
||
### Forbid / allow
|
||
|
||
Items have `is_forbidden: bool`. HaulingProvider skips forbidden items. UI exposes via long-press.
|
||
|
||
### Zone editing operations
|
||
|
||
- **Resize** — re-paint cells; update `cells` array.
|
||
- **Delete** — items left behind become loose Items, added to `_dirty_items`.
|
||
- **Filter change** — items now disallowed become loose, marked dirty.
|
||
- **Conflict** — disallow paint where cells overlap another zone (red overlay).
|
||
|
||
### Save format
|
||
|
||
Zones, containers, items, all serialize via existing per-entity save logic. ItemStack contents per-cell-or-per-container. JobRunner mid-toil state (carrying X of Y, walking to destination Z) round-trips.
|
||
|
||
## Mood, lighting, rooms, beauty, quality, cleaning
|
||
|
||
The full-fidelity colonist systems. Mechanics in [`design.md`](./design.md); this section is data + algorithms.
|
||
|
||
### MoodSystem
|
||
|
||
Per-pawn mood is a single 0–100 score recomputed each tick (or when thought modifiers change):
|
||
|
||
```gdscript
|
||
class Thought:
|
||
var id: String # "saw_corpse", "slept_well_indoors", ...
|
||
var modifier: float # +/- mood points
|
||
var stacks: int # for thoughts that compound
|
||
var expires_at_tick: int # 0 = persistent
|
||
var source_ref: Variant # the corpse, the bed, etc.
|
||
|
||
class Pawn:
|
||
var thoughts: Array[Thought]
|
||
var mood: float # cached, recomputed when thoughts change
|
||
|
||
func compute_mood(p: Pawn) -> float:
|
||
var m = 50.0 # base
|
||
for t in p.thoughts:
|
||
m += t.modifier * min(t.stacks, MAX_STACKS_PER_THOUGHT)
|
||
return clamp(m, 0, 100)
|
||
```
|
||
|
||
`MAX_STACKS_PER_THOUGHT = 5`. Caps "saw corpse" / "ate without table" / "slept on floor" pile-ups so a single thought type can't deterministically tank mood by itself; the player still loses points from variety, which matches the Rimworld feel.
|
||
|
||
Thought registry (data file) drives content. Each thought entry knows:
|
||
- Trigger: what game event creates it (saw_corpse from EntityProximity, slept_well from JobComplete, hungry from NeedThreshold, …)
|
||
- Lifetime: persistent (driven by ongoing state) or event (decays after N hours)
|
||
- Stacking rule: stack on repeat, or refresh duration
|
||
|
||
Add a thought = add a registry entry + a trigger handler. ~10 lines per thought.
|
||
|
||
### Soft break behavior
|
||
|
||
```gdscript
|
||
class Pawn:
|
||
var break_state: BreakState = BreakState.NONE # NONE / SULKING / WANDERING
|
||
var low_mood_since_tick: int = -1
|
||
|
||
func _on_sim_tick(...):
|
||
if mood < 25:
|
||
if low_mood_since_tick == -1: low_mood_since_tick = current_tick
|
||
elif current_tick - low_mood_since_tick > THIRTY_MIN_TICKS:
|
||
if break_state == BreakState.NONE:
|
||
break_state = randf() < 0.5 ? SULKING : WANDERING
|
||
else:
|
||
low_mood_since_tick = -1
|
||
|
||
if break_state == SULKING:
|
||
# override Decision pipeline: walk to nearest quiet tile, sit
|
||
elif break_state == WANDERING:
|
||
# walk to random nearby tiles, refuse work
|
||
|
||
if mood >= 35: break_state = BreakState.NONE
|
||
```
|
||
|
||
Break state slots into the Decision pipeline at Layer 1 step 2.5 (between FORCED and CRITICAL NEED) — sulking pawns ignore non-life-threatening things; critical bleeding still pulls them out.
|
||
|
||
### LightingSystem
|
||
|
||
Each `LightSource` (Furniture subtype) has `position`, `radius`, optional `color`. The system maintains a per-tile `light_level: float (0..1)`:
|
||
|
||
```gdscript
|
||
# LightingSystem.gd
|
||
var light_map: PackedFloat32Array # one float per tile, indexed by cell
|
||
|
||
func recompute_light(affected_cells: Array[Vector2i]):
|
||
for cell in affected_cells:
|
||
var v = 0.0
|
||
for src in nearby_light_sources(cell, max_radius=8):
|
||
var d = cell.distance_to(src.position)
|
||
if d <= src.radius:
|
||
v += 1.0 - (d / src.radius)
|
||
light_map[index(cell)] = clamp(v, 0, 1)
|
||
```
|
||
|
||
Recomputed only when a light source is added/removed/state-changes (turned off due to fuel, etc.). Cheap.
|
||
|
||
**Render side:** at night, world rendered with darkness shader; the shader samples `light_map` to brighten tiles. Phone GPU runs this comfortably for the MVP 80×80 map (and the 120² ceiling) — light recompute touches only cells inside the affected light's radius (max_radius=8 → ≤ 200 cells), not the whole map.
|
||
|
||
**Mood integration:** `is_lit(cell)` returns `light_map[cell] > 0.2`. Fires "In darkness" thought for pawns in unlit tiles at night.
|
||
|
||
### RoomDetector — extends EnclosureDetector
|
||
|
||
Triggered after enclosure changes; identifies discrete enclosed areas as `Room` records:
|
||
|
||
```gdscript
|
||
class Room:
|
||
var id: int
|
||
var cells: Array[Vector2i]
|
||
var furniture_in_room: Array[Furniture]
|
||
var inferred_type: String # "Bedroom" / "Kitchen" / "Dining Hall" / "Hallway" / "Room"
|
||
var beauty: float # cached, recomputed on furniture change
|
||
var dirtiness_avg: float # cached
|
||
var owners: Array[Pawn] # pawns who own a bed here
|
||
|
||
func recompute_rooms():
|
||
# Flood-fill all interior cells, group into Room records
|
||
# Categorize by dominant furniture
|
||
...
|
||
```
|
||
|
||
Updated when walls / furniture change. Uses the same enclosure machinery as auto-roof — just one more pass over the same data.
|
||
|
||
### Beauty score
|
||
|
||
Each Furniture/Floor entity has `beauty: int` baked into its data definition. Quality multiplies it (Masterwork = ×3 beauty contribution). Room beauty is a weighted average of cell-beauty contributions with falloff for items more than 4 tiles away (so a statue helps the whole room).
|
||
|
||
### Quality system
|
||
|
||
```gdscript
|
||
enum Quality { SHODDY, NORMAL, EXCELLENT, MASTERWORK, LEGENDARY }
|
||
|
||
func roll_quality(skill: int, base_chance_modifier: float = 0.0) -> Quality:
|
||
# Higher skill shifts the distribution
|
||
var roll = randf() + skill * 0.04 + base_chance_modifier
|
||
if roll > 1.4: return LEGENDARY
|
||
elif roll > 1.1: return MASTERWORK
|
||
elif roll > 0.7: return EXCELLENT
|
||
elif roll > 0.3: return NORMAL
|
||
else: return SHODDY
|
||
```
|
||
|
||
Quality multipliers (applied on item create):
|
||
|
||
```
|
||
SHODDY 0.7×
|
||
NORMAL 1.0×
|
||
EXCELLENT 1.25×
|
||
MASTERWORK 1.5×
|
||
LEGENDARY 2.0×
|
||
```
|
||
|
||
Stored as `quality: Quality` on every craftable Item entity. UI color-codes item name by quality.
|
||
|
||
### Dirtiness simulation
|
||
|
||
```gdscript
|
||
# DirtinessSystem.gd
|
||
var dirty_map: PackedFloat32Array # per-tile dirtiness 0..100
|
||
|
||
func _on_pawn_walk_through(cell: Vector2i, pawn: Pawn):
|
||
# Pawns track in mud, blood, etc. Outdoor → indoor pawns track more.
|
||
dirty_map[index(cell)] += pawn.tracking_amount * dt
|
||
```
|
||
|
||
Spike sources: combat blood, corpse near tile, spilled food.
|
||
Decay: zero — dirtiness only goes down via Cleaning.
|
||
|
||
### CleaningProvider
|
||
|
||
8th WorkProvider in the existing pawn AI. Scans dirty tiles in/near rooms with priority bias toward indoor tiles. Job toils: walk → clean (timed by Manual Labor skill) → set dirtiness to 0.
|
||
|
||
## Production: workbenches, recipes, bills
|
||
|
||
For player rules and recipe lists, see [`design.md`](./design.md). This section is data + AI plug-in.
|
||
|
||
### Recipe registry (data)
|
||
|
||
```gdscript
|
||
class Recipe:
|
||
var id: String
|
||
var workbench_type: String # "smithy", "cooking_hearth", ...
|
||
var inputs: Dictionary # {item_id: qty}
|
||
var outputs: Dictionary # {item_id: qty}; quality rolled at create
|
||
var skill: SkillType # Crafting / Cooking
|
||
var duration_ticks: int
|
||
var min_skill_required: int = 0
|
||
```
|
||
|
||
Loaded from a JSON/Godot resource at startup. Adding a recipe = adding a row.
|
||
|
||
### Workbench (Furniture subtype)
|
||
|
||
```gdscript
|
||
class Workbench extends Furniture:
|
||
var workbench_type: String # "smithy", "carpenter_bench", ...
|
||
var output_cell: Vector2i # cell in front for product drop
|
||
var bills: Array[Bill] # queue
|
||
var current_bill: Bill = null # actively being worked
|
||
var current_pawn: Pawn = null # who's working it
|
||
```
|
||
|
||
### Bill (queue entry)
|
||
|
||
```gdscript
|
||
class Bill:
|
||
var recipe_id: String
|
||
var mode: BillMode # ONE_SHOT / FOREVER / UNTIL_N
|
||
var count_remaining: int # for ONE_SHOT
|
||
var stockpile_threshold: int # for UNTIL_N
|
||
var ingredient_quality_min: Quality = Quality.SHODDY
|
||
var pawn_skill_min: int = 0
|
||
var paused: bool = false
|
||
|
||
func is_active() -> bool:
|
||
if paused: return false
|
||
if mode == ONE_SHOT: return count_remaining > 0
|
||
if mode == FOREVER: return true
|
||
if mode == UNTIL_N: return World.count_in_storage(recipe.outputs) < stockpile_threshold
|
||
```
|
||
|
||
### CraftingProvider — 9th WorkProvider
|
||
|
||
```gdscript
|
||
# WorkProvider_Crafting.gd
|
||
func find_best_for(pawn: Pawn) -> Job:
|
||
if pawn.skills[Crafting] < required: return null # respects pawn skill
|
||
var best: (Workbench, Bill) = null
|
||
var best_score = -INF
|
||
|
||
for bench in World.workbenches:
|
||
if bench.skill != Crafting: continue
|
||
for bill in bench.bills:
|
||
if not bill.is_active(): continue
|
||
if pawn.skills[Crafting] < bill.pawn_skill_min: continue
|
||
if not ingredients_available(bill, bench): continue
|
||
|
||
var score = bill_priority_in_queue(bill, bench) * 100 \
|
||
- distance(pawn.position, bench.position)
|
||
if score > best_score:
|
||
best_score = score
|
||
best = (bench, bill)
|
||
|
||
return best ? Job_Craft(bench, bill) : null
|
||
```
|
||
|
||
A separate **CookingProvider** mirrors this for cooking workbenches (hearth) using the Cooking skill.
|
||
|
||
### Crafting Job toils
|
||
|
||
```
|
||
walk_to(workbench)
|
||
→ for each input: walk_to(stockpile-or-crate-with-input), pick_up, walk_back, drop_at_workbench
|
||
→ work(duration_ticks, modified_by_skill)
|
||
→ on_complete: roll_quality(skill), spawn output Item with quality, place at workbench.output_cell
|
||
→ if mode == ONE_SHOT: count_remaining -= 1; if 0 remove bill
|
||
→ if mode == UNTIL_N: bill auto-deactivates next tick when threshold met
|
||
→ HaulingProvider clears the output via normal pipeline
|
||
```
|
||
|
||
### Ingredient acquisition radius
|
||
|
||
For MVP: pawn searches **all** stockpiles + containers globally for ingredients. Real restriction (per-bench radius) is a v2 polish item — bench can be configured to only pull from stockpiles within K tiles.
|
||
|
||
### Skill-rolled quality on output
|
||
|
||
Reuses the Quality system already designed. `roll_quality(pawn.skills[recipe.skill])` returns Quality enum, multiplied into item stats on creation. UI color-codes the resulting name.
|
||
|
||
### Bill UI state — saved per workbench
|
||
|
||
Bills round-trip in saves as part of the workbench entity serialization. Mid-job state (pawn currently fetching iron ingot 2 of 4) is JobRunner state, already serializable.
|
||
|
||
### Updated WorkProvider list (9)
|
||
|
||
Construction · Mining · Hauling · Cleaning · **Crafting** · Cooking · Plant · Doctor · Combat
|
||
|
||
## Combat system
|
||
|
||
For weapon/armor stats, hit math, and combat priority semantics, see [`design.md`](./design.md). This section is tech.
|
||
|
||
### Equipment data
|
||
|
||
```gdscript
|
||
class Weapon extends Item:
|
||
var weapon_type: String # "sword" / "axe" / "bow"
|
||
var base_damage: int
|
||
var range_tiles: int # 1 for melee, 8 for bow
|
||
var attacks_per_sec: float
|
||
var quality: Quality
|
||
func effective_damage() -> float:
|
||
return base_damage * QUALITY_MULTIPLIER[quality]
|
||
|
||
class Armor extends Item:
|
||
var slot: ArmorSlot # HELMET / CUIRASS / BOOTS
|
||
var base_armor: int
|
||
var quality: Quality
|
||
func effective_armor() -> float:
|
||
return base_armor * QUALITY_MULTIPLIER[quality]
|
||
|
||
class Pawn:
|
||
var equipped_weapon: Weapon = null
|
||
var equipped_armor: Dictionary = {} # slot -> Armor
|
||
var locked_assignments: Dictionary # for player-overridden auto-equip
|
||
```
|
||
|
||
### Auto-equip system
|
||
|
||
Each tick (cheap), if a Combat-enabled pawn has no weapon equipped and a valid one is in reach, queues a `Job_Equip`. Toils: walk → pick up → equip. Player long-press → "Assign to X" overrides this with a locked assignment.
|
||
|
||
### CombatSystem — auto-pause + threat detection
|
||
|
||
Singleton:
|
||
|
||
```gdscript
|
||
# CombatSystem.gd
|
||
signal threat_appeared(threat: Entity)
|
||
signal pawn_downed(pawn: Pawn)
|
||
|
||
func _on_sim_tick():
|
||
var threats = scan_for_threats() # hostile pawns/animals near colonists
|
||
for t in threats:
|
||
if not _known_threats.has(t):
|
||
emit_signal("threat_appeared", t) # Time/tick model auto-pauses on this
|
||
_known_threats[t] = current_tick
|
||
```
|
||
|
||
### Hit / damage resolution
|
||
|
||
```gdscript
|
||
# CombatJob toil (per attack tick of attacker)
|
||
func resolve_attack(attacker: Pawn, target: Entity):
|
||
var dist = attacker.position.distance_to(target.position)
|
||
if dist > attacker.equipped_weapon.range_tiles:
|
||
return # out of range, attacker walks closer
|
||
|
||
var hit_chance = 0.5 \
|
||
+ attacker.skills[Combat] * 0.05 \
|
||
- cover_penalty(attacker.position, target.position) \
|
||
- range_penalty(dist, attacker.equipped_weapon)
|
||
hit_chance = clamp(hit_chance, 0.05, 0.95)
|
||
|
||
if randf() < hit_chance:
|
||
var dmg = attacker.equipped_weapon.effective_damage() \
|
||
- target_total_armor(target)
|
||
dmg = max(1, dmg)
|
||
target.hp -= dmg
|
||
if target.hp <= 0:
|
||
handle_down_or_death(target)
|
||
emit_signal("attack_resolved", attacker, target, true, dmg)
|
||
else:
|
||
emit_signal("attack_resolved", attacker, target, false, 0)
|
||
# If projectile: chance to hit something else along LOS path
|
||
if attacker.equipped_weapon.weapon_type == "bow":
|
||
check_friendly_fire(attacker, target)
|
||
```
|
||
|
||
### Cover detection
|
||
|
||
```gdscript
|
||
func cover_penalty(attacker: Vector2i, target: Vector2i) -> float:
|
||
var path = bresenham_line(attacker, target)
|
||
var max_cover = 0.0
|
||
for cell in path[1..-2]: # exclude endpoints
|
||
if World.has_wall_at(cell): max_cover = max(max_cover, 0.4)
|
||
elif World.has_tree_at(cell): max_cover = max(max_cover, 0.2)
|
||
return max_cover
|
||
```
|
||
|
||
Bresenham is microseconds at our distances.
|
||
|
||
### Downed system
|
||
|
||
```gdscript
|
||
# DownedSystem.gd
|
||
func handle_down_or_death(pawn: Pawn):
|
||
if pawn.has_status(Status.DOWNED):
|
||
# Already downed; this attack causes death
|
||
pawn.die()
|
||
else:
|
||
pawn.add_status(Status.DOWNED)
|
||
pawn.drop_weapon()
|
||
World.mark_downed_pawn(pawn) # Doctors prioritize
|
||
World.start_bleed_timer(pawn, BLEED_OUT_TICKS)
|
||
|
||
func _on_bleed_timer_expired(pawn: Pawn):
|
||
if pawn.has_status(Status.DOWNED) and not pawn.in_bed:
|
||
pawn.die() # death
|
||
|
||
func handle_pawn_in_bed(pawn: Pawn):
|
||
if pawn.has_status(Status.DOWNED):
|
||
pawn.cancel_bleed_timer()
|
||
pawn.add_status(Status.RESTING)
|
||
pawn.heal_over_time = true
|
||
```
|
||
|
||
### Doctor work — Downed priority
|
||
|
||
`WorkProvider_Doctor` already exists in the 9-WorkProvider list. Adds a high-priority scan: any Downed pawn within reasonable distance gets a Job_Rescue ahead of normal treatment work.
|
||
|
||
Job toils:
|
||
```
|
||
walk_to(downed) → pick_up → walk_to(nearest_unoccupied_bed) → place_in_bed
|
||
→ optionally: walk to medical supplies, walk back, treat → reduce bleeding/infection
|
||
```
|
||
|
||
### Combat priority — "defends if cornered"
|
||
|
||
```gdscript
|
||
# When threat appears + pawn has Combat=Off:
|
||
func handle_threat_for_off_pawn(pawn: Pawn, threat: Entity):
|
||
var escape = find_path_away_from(pawn, threat, max_steps=5)
|
||
if escape: pawn.queue_job(Job_FleeTo(escape))
|
||
else: pawn.engage(threat) # cornered, fight back with whatever is at hand
|
||
```
|
||
|
||
### Wolf AI (MVP threat)
|
||
|
||
`Wolf` is an `Animal` entity with its own state machine, separate from the pawn ThinkTree:
|
||
|
||
```gdscript
|
||
states: APPROACH → ENGAGE → FLEE (at HP < 30%) → DEAD
|
||
```
|
||
|
||
Spawned at map edge by storyteller wolf event. Native attack: 6 damage, 1.0/sec. Tough hide armor: 2. Drops a butcherable corpse.
|
||
|
||
## Failure state, death, corpses, burial
|
||
|
||
For mechanics see [`design.md`](./design.md). This section is data + AI plug-in.
|
||
|
||
### Ghost colony state
|
||
|
||
```gdscript
|
||
# World.gd
|
||
enum WorldState { ACTIVE, GHOST }
|
||
var state: WorldState = ACTIVE
|
||
var ghost_started_at_tick: int = 0
|
||
|
||
func _on_pawn_died(pawn: Pawn):
|
||
if World.living_pawns.is_empty():
|
||
enter_ghost_state()
|
||
|
||
func enter_ghost_state():
|
||
state = GHOST
|
||
ghost_started_at_tick = current_tick
|
||
sim.set_speed_factor(0.5) # half-time during ghost
|
||
StorytellerSystem.schedule_event("wanderer_arrival",
|
||
min_delay_days = 3,
|
||
max_delay_days = 5)
|
||
UI.show_ghost_banner("Your colony has fallen silent. Watch for travelers.")
|
||
|
||
func on_recruit_wanderer(new_pawn: Pawn):
|
||
state = ACTIVE
|
||
sim.set_speed_factor(1.0)
|
||
UI.hide_ghost_banner()
|
||
```
|
||
|
||
While in ghost state, render normally — buildings/items persist. Pawn-AI sim does nothing because no pawns. Storyteller still ticks for wanderer arrival.
|
||
|
||
### Corpse entity
|
||
|
||
```gdscript
|
||
class Corpse extends Item:
|
||
var pawn_record: PawnRecord # the dead pawn's full record
|
||
var decay_severity: float = 0.0 # 0..100
|
||
var death_cause: String # "killed by wolf", "starvation", "bleed out", ...
|
||
var death_day: int
|
||
|
||
func _on_in_game_hour():
|
||
decay_severity += 33.0 / 24.0 # ~33 per day
|
||
if decay_severity >= 50 and not has_status("Rotting"):
|
||
add_status("Rotting")
|
||
update_mood_penalty()
|
||
```
|
||
|
||
Butchering disabled when `decay_severity >= 50` (cooking-hearth recipe checks).
|
||
|
||
### PawnRecord (preserved after death)
|
||
|
||
```gdscript
|
||
class PawnRecord: # not a node; data only
|
||
var name: String
|
||
var backstory: String
|
||
var skills_at_death: Dictionary
|
||
var death_day: int
|
||
var death_cause: String
|
||
var days_lived: int
|
||
# ... statuses at death, equipment at death, etc.
|
||
```
|
||
|
||
Stored on the Corpse and on any GraveMarker built from this corpse. Pawn-detail UI for deceased pawns reads from PawnRecord.
|
||
|
||
### BurialJob
|
||
|
||
A new job type. Created by HaulingProvider when:
|
||
- A Graveyard zone exists (a Stockpile with ONLY the Corpses category enabled)
|
||
- An unburied corpse exists in the world
|
||
|
||
Toils:
|
||
|
||
```
|
||
walk_to(corpse_cell) → pick_up(corpse)
|
||
→ walk_to(target_grave_cell) # an empty tile in the graveyard
|
||
→ dig_grave (duration_ticks = 600 / manual_labor_skill_modifier)
|
||
→ place_grave_marker(pawn_record)
|
||
→ destroy(corpse)
|
||
→ on_complete: GraveMarker entity placed at grave_cell
|
||
```
|
||
|
||
The DigGraveJob uses Construction work category (it's manual digging + structure placement).
|
||
|
||
### GraveMarker entity
|
||
|
||
```gdscript
|
||
class GraveMarker extends Furniture:
|
||
var pawn_record: PawnRecord
|
||
var beauty: int = 1
|
||
func on_tap() -> void:
|
||
UI.show_pawn_detail(pawn_record) # deceased view
|
||
```
|
||
|
||
Persists indefinitely. Cluster of markers in a graveyard zone contributes ~+1 beauty per marker to the room/area. Tap → pawn-detail showing the deceased's record.
|
||
|
||
### Cremation pyre — Furniture + Workbench
|
||
|
||
Build via Build → Furniture → Pyre. Stats:
|
||
|
||
- Cost: 10 stone + 5 wood
|
||
- Output cell: 1 tile in front
|
||
- Recipes: just "Cremate corpse"
|
||
|
||
Cremate recipe:
|
||
```
|
||
inputs: [{corpse: 1}, {wood: 5}]
|
||
outputs: [] # corpse destroyed; no item produced
|
||
skill: CRAFTING
|
||
duration_ticks: 400
|
||
```
|
||
|
||
Player queues a "Cremate forever" bill on the pyre; a Crafting-priority pawn auto-hauls a corpse from anywhere, performs the rite. Visual: 5-second flame + smoke FX.
|
||
|
||
### Storyteller wanderer event
|
||
|
||
```gdscript
|
||
# StorytellerSystem.gd
|
||
func fire_wanderer_event():
|
||
var wanderer = generate_pawn() # random name + skills
|
||
UI.show_modal_event(
|
||
title = "A traveler appears",
|
||
body = "%s wandered to your colony. They are tired and looking for shelter." % wanderer.name,
|
||
choices = [
|
||
{ label = "Welcome them", on_pick = lambda(): World.add_pawn(wanderer) },
|
||
{ label = "Send them away", on_pick = lambda(): pass }
|
||
]
|
||
)
|
||
```
|
||
|
||
If sent away: schedule another wanderer event 3–5 days later. The event always eventually succeeds — ghost state is recoverable, never terminal.
|
||
|
||
## Storyteller system
|
||
|
||
For mechanism rules and the prompt corpus, see [`design.md`](./design.md). This section is data + picker.
|
||
|
||
### Prompt registry (data)
|
||
|
||
```gdscript
|
||
class StorytellerEvent:
|
||
var id: String
|
||
var category: Category # NUDGE / SEASONAL / WANDERER / THREAT / DISEASE / RESOURCE / LORE / MILESTONE
|
||
var title_key: String # i18n key
|
||
var body_key: String # i18n key, may contain %pawn% substitution
|
||
var trigger: TriggerSpec # how this fires
|
||
var effect: EffectSpec # what happens
|
||
var weight: float # base pool weight
|
||
var cooldown_days: int # min days before this exact event can fire again
|
||
```
|
||
|
||
Loaded from a JSON registry at startup. Adding events is a row in the file.
|
||
|
||
### TriggerSpec
|
||
|
||
```gdscript
|
||
class TriggerSpec:
|
||
var type: TriggerType # RANDOM / STATE / TIME / SEASONAL_EVENT
|
||
var min_day: int = 0
|
||
var state_predicate: Callable = null # for STATE triggers
|
||
var season_filter: Array[Season] = []
|
||
var requires_pawn_count: int = 0
|
||
```
|
||
|
||
Examples:
|
||
- "First Beds" → STATE, predicate: `World.bed_count < World.living_pawns.size()`, min_day=2.
|
||
- "Spring Awakens" → SEASONAL_EVENT, season=SPRING, fires-on-season-start.
|
||
- "Wolves at the Edge" → RANDOM, season-weighted.
|
||
|
||
### Picker — daily roll
|
||
|
||
```gdscript
|
||
# StorytellerSystem.gd
|
||
func _on_daily_tick():
|
||
var pool = build_weighted_pool()
|
||
var event = weighted_pick(pool)
|
||
if event: fire_event(event)
|
||
|
||
func build_weighted_pool() -> Dictionary:
|
||
var pool = {}
|
||
for ev in EVENT_REGISTRY.values():
|
||
if not ev.trigger.matches(World.state, current_day): continue
|
||
# Both gates must pass: per-event AND per-category cooldown.
|
||
# Per-event prevents the *same* event repeating within its individual cooldown.
|
||
# Per-category enforces the design.md pacing rule (no two threats within 3 days, etc.).
|
||
if recently_fired(ev.id, ev.cooldown_days): continue
|
||
if recently_fired_category(ev.category, CATEGORY_COOLDOWN[ev.category]): continue
|
||
var w = ev.weight * tension_modifier(ev.category)
|
||
pool[ev] = w
|
||
return pool
|
||
|
||
func tension_modifier(category: Category) -> float:
|
||
if category == THREAT and tension_score > HIGH_THRESHOLD: return 0.3
|
||
if category == THREAT and tension_score < LOW_THRESHOLD: return 2.0
|
||
return 1.0
|
||
```
|
||
|
||
### Tension model
|
||
|
||
```gdscript
|
||
var tension_score: float = 0.0 # 0..100
|
||
|
||
# On each event fire:
|
||
match event.category:
|
||
THREAT: tension_score = min(100, tension_score + 30)
|
||
DISEASE: tension_score = min(100, tension_score + 20)
|
||
WANDERER (Welcomed): tension_score = max(0, tension_score - 10)
|
||
SEASONAL: pass
|
||
NUDGE: pass
|
||
|
||
# Decay over time:
|
||
func _on_in_game_hour():
|
||
tension_score = max(0, tension_score - 0.5) # ~12/day decay
|
||
```
|
||
|
||
Hand-tuned from playtest.
|
||
|
||
### State-trigger predicates
|
||
|
||
A handful of common state predicates (Callable on `World`):
|
||
|
||
```gdscript
|
||
func no_beds(): return World.bed_count < World.living_pawns.size()
|
||
func no_farm(): return World.farm_zones.is_empty()
|
||
func no_walls(): return World.wall_count == 0
|
||
func no_fire(): return World.light_sources.is_empty()
|
||
func sick_pawn_exists(): return World.living_pawns.any(|p| p.has_status(SICK))
|
||
func is_winter(): return World.current_season == Season.WINTER
|
||
```
|
||
|
||
### Event firing & UI dispatch
|
||
|
||
```gdscript
|
||
func fire_event(ev: StorytellerEvent):
|
||
var ctx = build_context(ev) # %pawn% substitution etc.
|
||
match ev.category:
|
||
NUDGE, SEASONAL, LORE:
|
||
UI.show_ambient_banner(ev.title_key, ev.body_key, ctx)
|
||
WANDERER, THREAT, DISEASE, MILESTONE:
|
||
sim.auto_pause()
|
||
UI.show_modal_event(ev.title_key, ev.body_key, ev.choices, ctx)
|
||
RESOURCE:
|
||
UI.show_ambient_banner(...)
|
||
apply_effect(ev.effect, ctx)
|
||
```
|
||
|
||
Effects are applied either immediately (resource bonuses, status applications) or scheduled (wolves spawn 2 in-game hours later).
|
||
|
||
### Save format
|
||
|
||
Storyteller state: tension score, recent-event log (last 30 days), per-category cooldown timers, scheduled-effect queue. Trivial to round-trip.
|
||
|
||
## Roofing, enclosure, weather
|
||
|
||
For mechanics (mood, weather table, what roofs protect against) see [`design.md`](./design.md). This section covers data + algorithms.
|
||
|
||
### Roof flag on Layer 4
|
||
|
||
Layer 4 of the TileMap stores a single tile id "roofed" (or unset). The set/unset flag is the authoritative "is this tile indoors?" — no further enclosure check at sim-read time.
|
||
|
||
### EnclosureDetector — auto-roof when walls change
|
||
|
||
Triggered on any Layer 2 (Wall) edit:
|
||
|
||
```gdscript
|
||
# pseudocode
|
||
func on_wall_change(cell: Vector2i):
|
||
var affected = neighbors_within(cell, RADIUS=8)
|
||
for candidate in affected:
|
||
if is_already_wall_or_roofed(candidate): continue
|
||
if bfs_finds_exit(candidate, max_steps=8):
|
||
unset_roof(candidate) # area opened up, was roof, isn't now
|
||
else:
|
||
set_roof(candidate)
|
||
```
|
||
|
||
`bfs_finds_exit(start)`: flood from `start`, treating walls as blockers; return true if we reach the map edge or a No-Roof designated cell within 8 steps. Fast at our scale (8² = 64 cells worst case per BFS, called on each candidate within the affected radius — still microseconds).
|
||
|
||
**Big-room feedback.** When `bfs_finds_exit` returns true *because the BFS ran out of steps without escaping or roofing* (i.e. the area is enclosed but larger than the cap), the EnclosureDetector emits a `room_too_large` signal carrying the centroid cell. The UI raises a one-shot ambient banner: "*This area is too large to roof. Split it with an interior wall.*" Threshold and exact UX wording TBD — see `memory.md` Open questions.
|
||
|
||
When a wall is destroyed: candidates are re-evaluated; some may flip from roof to no-roof.
|
||
|
||
### No-Roof designation
|
||
|
||
A separate persistent set on the World scene: `no_roof_cells: Dictionary[Vector2i, true]`. EnclosureDetector treats no-roof cells as map-exits during BFS, so they never become roofed. Painted via the standard designation paint mode.
|
||
|
||
### Indoor tint
|
||
|
||
Render-side: indoor tiles (roof flag set) get a subtle dark-blue overlay on the floor layer. Outdoor tiles render normally. Cheap shader uniform driven by tile metadata.
|
||
|
||
### WeatherSystem — singleton
|
||
|
||
```gdscript
|
||
# WeatherSystem.gd (autoload singleton)
|
||
var current: Weather = Weather.CLEAR
|
||
var current_season: Season = Season.SPRING
|
||
|
||
func _on_sunrise():
|
||
var weights = SEASON_WEATHER_WEIGHTS[current_season]
|
||
current = Weather.weighted_pick(weights)
|
||
emit_signal("weather_changed", current)
|
||
|
||
# Subscribers:
|
||
# PawnSystem — applies Wet status to outdoor pawns at tick rate
|
||
# Renderer — adjusts sky tint, rain particles
|
||
# JobSystem — disables harvesting jobs in Storm
|
||
```
|
||
|
||
Storyteller can pre-empt: `WeatherSystem.set_for_next_day(Weather.STORM)` for narrative events.
|
||
|
||
### Wet status integration
|
||
|
||
`Wet` is just another status in the existing pawn AI ([Pawn AI / job system](#pawn-ai--job-system)). Each tick, for each pawn:
|
||
|
||
```
|
||
if outdoors and weather in {RAIN, STORM}:
|
||
wet_severity += accumulation_rate(weather) * dt
|
||
elif indoors:
|
||
wet_severity = max(0, wet_severity - 5 * dt_in_game_hours)
|
||
```
|
||
|
||
Mood thoughts derived from severity bands (see design.md table).
|
||
|
||
### Snow accumulation
|
||
|
||
Optional polish: per-tile `snow_level: float (0..3)` on outdoor tiles in winter. Visual decal that slows movement when ≥ 3. Cheap dirty-set update at sunrise during winter.
|
||
|
||
### Save format
|
||
|
||
- Roof: serialized via Layer 4 `get_used_cells_by_id` like other layers.
|
||
- No-roof designations: `Array[Vector2i]`.
|
||
- Weather: current state + current season + day-of-season counter.
|
||
- Wet status: per-pawn severity (already in pawn save).
|
||
|
||
## Engine architecture (Godot 4)
|
||
|
||
**Architectural split:** static, grid-aligned stuff is `TileMap`. Stateful, named, or multi-tile things are scene-instanced nodes (`PackedScene`). Don't try to put everything in the TileMap.
|
||
|
||
### TileMap layers
|
||
|
||
| Layer | Purpose | Source |
|
||
|---|---|---|
|
||
| 0 Terrain | grass / dirt / stone / water | world-gen, mostly static |
|
||
| 1 Floor | built wood / stone / carpet | player-placed |
|
||
| 2 Wall | walls — Godot terrain autotile | player-placed |
|
||
| 3 Designation | blueprint ghosts, chop / mine marks | overlay |
|
||
| 4 Roof | flag tiles for "is this roofed?" | mostly invisible, sim use |
|
||
| 5 Fog | optional fog-of-war / unseen | overlay |
|
||
|
||
### Entity nodes
|
||
|
||
Children of a `World` node, tile-aligned positions:
|
||
|
||
- **Furniture** — bed, stove, workbench, door, lamp. PackedScene per type. Tracked in a `furniture_by_cell` dictionary for fast lookup. Multi-tile shapes occupy multiple cells.
|
||
- **Pawn** — `CharacterBody2D` + state-machine/utility AI. Named, persistent.
|
||
- **Item** — dropped logs, stone, food, corpses. Stack qty + decay timers.
|
||
- **EffectFX** — muzzle flashes, blood, smoke. Transient.
|
||
|
||
### Build flow
|
||
|
||
1. Player taps **Build** → bottom-sheet drawer (Walls / Floors / Furniture / Production).
|
||
2. Pick "Wall" → designation mode; finger-drag paints ghosts on Layer 3, green-if-placeable / red-if-blocked.
|
||
3. Confirm → each ghost becomes a `BuildJob` in a queue with material cost.
|
||
4. Idle Construction-priority pawn picks nearest job: haul materials → walk to blueprint → work N ticks → clear Layer 3, set Layer 2 (terrain autotile fixes neighbors), update pathfinder.
|
||
5. Same flow generalizes to floors, mining (terrain → log/stone drop), chopping (tree entity → log drop), deconstruction (reverse).
|
||
|
||
### Pathfinding
|
||
|
||
`AStarGrid2D`, one grid the size of the map. Walkable derived from TileMap data + furniture occupancy. Update affected cells on build/destroy/door-state-change (one cell per change → O(1)). Sub-millisecond per path query at 80²; still well under a millisecond at the 120² ceiling.
|
||
|
||
### Saving
|
||
|
||
Per layer, `tilemap.get_used_cells_by_id(layer)` → JSON. Plus furniture entities, pawns, items, job queue, time, storyteller state. Mid-tick suspend is safe as long as we save **between** sim ticks (we own the tick loop). JobRunner state must be serializable — design constraint from day one.
|
||
|
||
### Performance
|
||
|
||
80×80 × 5 layers = 32k tiles (MVP); 120×120 × 5 = 72k tiles (ceiling). GPU-batched in one draw call per layer regardless of cell count, so the bump is essentially free for the renderer. ~100–500 entity nodes at peak. Comfortable 60 fps on a mid-range phone with headroom. iOS export needs Mac/Xcode; Android export from Linux is fine.
|
||
|
||
## What lives elsewhere
|
||
|
||
- **Game design / mechanics / simplifications** — see [`design.md`](./design.md).
|
||
- **Touch UI** — see [`ui.md`](./ui.md).
|
||
- **Tileset / art** — see [`art.md`](./art.md).
|
||
- **Pillars and vertical slice** — see [`memory.md`](../memory.md).
|