Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
World.clear_all + 4 EventBus signals (save_started/finished, load_started/
finished) before dispatch. Pattern proven across Phases 12/13/14/15/16.
Entity to_dict/from_dict + class_id tagging (Agent A):
- class_id tag added to all 18 entity to_dict methods for loader routing
- Missing pairs filled in: wolf, grave_slot, graveyard_zone, stockpile_zone,
crate (from_dict). All defensive with d.get(field, default).
- Workbench round-trips label_text so Carpenter/Smelter/Millstone/Hearth/
Pyre kinds survive reload
- BeautySystem + DirtinessSystem save_dict/apply_dict for sparse maps
- World.save_tilemap_layers / apply_tilemap_layers covering 5 layers
(Terrain/Floor/Wall/Designation/Roof; Fog runtime-only skipped)
SaveSystem v2 rewrite (Agent B):
- SAVE_VERSION bumped from 1 to 2
- write_save(slot) pauses Sim, emits save_started, collects every entity
via _collect_entities iterating all World registries, writes payload to
user://save_<slot>.json
- apply_save full rewrite: pause sim → emit load_started → World.clear_all
→ apply autoloads (GameState/Clock/Weather/Storyteller) → apply tilemap
layers → iterate payload.entities and dispatch to per-class factories
→ apply beauty/dirt maps → emit load_finished(slot, ok, real_seconds_away)
- Per-class factory registry: 18 class_ids dispatched to setup+add_child+
from_dict patterns. CremationPyre detected via workbench.label_text == 'Pyre'
- Public slot API: save_to_slot/load_from_slot/has_save/delete_save/
peek_save_metadata. Slots locked: &manual + &autosave
Autosave + UI + Resume toast (Agent C):
- autoload/autosave.gd — new Autosave autoload. Periodic every
AUTOSAVE_INTERVAL_TICKS = 6000 (~5 in-game min at 20 Hz) + NOTIFICATION_
APPLICATION_PAUSED (mobile) + NOTIFICATION_WM_WINDOW_FOCUS_OUT (desktop).
Gated by _busy flag tied to EventBus.save_started/save_finished.
- TopBar extended with SaveBtn (💾) + LoadBtn buttons, 48×48 min hit area
- scenes/ui/load_menu.gd — CanvasLayer slot picker. Reads peek_save_metadata
to show 'Manual save (Date Time)' / 'Autosave (Date Time)' rows.
Version-mismatch warning dialog before continuing on older saves.
- scenes/ui/resume_toast.gd — top-center toast. On load_finished(ok=true):
'Welcome back — N minutes/hours away' for 5s + 0.8s fade.
On ok=false: 'Load failed (corrupt or version mismatch)'.
- Strings catalog: 14 new keys (ui.save / ui.load / ui.welcome_back_* /
ui.load_failed etc.)
- main.gd mounts LoadMenu + ResumeToast as runtime CanvasLayer children
MCP runtime verified:
- Saved at tick 1137 → [save] wrote slot 'manual': 113 entities at tick 1137
- Advanced sim to tick 4600 at ULTRA speed (different state)
- load_from_slot(&manual) → [save] applied slot 'manual': 113 entities,
0 errors, tick=1137, away=34s
- post-load: Sim.tick=1137 (restored), pawns alive=3, all furniture +
workbenches + crops + walls + floors back in place
- Resume toast fires: [resume_toast] showing — ok=true seconds_away=34
- Autosave on focus-loss verified: [autosave] focus-loss → wrote autosave
- Screenshot shows TopBar with Save + Load buttons + post-load Lone Wolf
storyteller modal from fresh dawn roll
Known acceptable gaps (deferred to Phase 20 tuning):
- Pawn JobRunner mid-INTERACT/mid-BUILD restarts from toil 0 on reload
(walk toil round-trips; multi-step interact does not). Pawns lose a few
seconds of work.
- Workbench bill mid-craft fetch state isn't fully serialized.
- Wolf.target_pawn re-resolution from name string is Agent A's documented
pattern; Agent B's apply_save respects pawn-restoration ordering so the
resolution works after pawns are back.
Delegation: 3× gdscript-refactor (Sonnet) agents in parallel; integration
+ MCP verify on Opus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251 lines
9.5 KiB
GDScript
251 lines
9.5 KiB
GDScript
class_name Torch extends Node2D
|
||
## Torch furniture entity — buildable wall-hung or floor-standing light source.
|
||
##
|
||
## Rendered as a small stick + wrapped head + flame using _draw() in the same
|
||
## 3/4-perspective convention as Wall / Bed / Workbench. Ghost state (40% alpha)
|
||
## while construction is in progress; solid + lit once _completed.
|
||
##
|
||
## Light model (docs/architecture.md "LightingSystem"):
|
||
## Emits a PointLight2D with a procedural radial-gradient texture (soft
|
||
## falloff). The Godot visual light is additive with CanvasModulate for
|
||
## night-time darkness. The sim-side light radius (LIGHT_RADIUS tiles,
|
||
## Manhattan distance) is registered with World.light_sources so that
|
||
## is_tile_lit() can answer "in darkness" thought queries cheaply.
|
||
##
|
||
## Light-source duck-type interface (shared with Hearth / Workbench):
|
||
## is_on() → bool
|
||
## get_light_tile() → Vector2i
|
||
## get_light_radius() → int
|
||
##
|
||
## Build model (same BuildJob interface as Wall / Bed / Workbench):
|
||
## BUILD_TICKS ticks via the standard BuildJob toil; PointLight2D enabled
|
||
## only after _complete().
|
||
##
|
||
## Save/load: to_dict / from_dict capture tile, label_text, build_progress,
|
||
## _completed, _is_on. Phase 16 wires these into the full save layer.
|
||
##
|
||
## World registration: World.register_light_source / World.unregister_light_source
|
||
## called from _ready / _exit_tree.
|
||
|
||
const TILE_SIZE_PX: int = 16
|
||
|
||
## Sim ticks to build a torch (30 ticks ≈ 1.5 sim seconds at 1×).
|
||
const BUILD_TICKS: int = 30
|
||
|
||
## Sim-side light radius in tiles (Manhattan). Max 8 per architecture.md.
|
||
const LIGHT_RADIUS: int = 6
|
||
|
||
## Pixel size of the procedural radial gradient used for the PointLight2D.
|
||
## Larger values give a smoother falloff; 64 is sufficient for a 6-tile radius.
|
||
const LIGHT_TEXTURE_SIZE: int = 64
|
||
|
||
# ── exports ───────────────────────────────────────────────────────────────────
|
||
|
||
## Tile position of this torch in world-tile coordinates.
|
||
@export var tile: Vector2i = Vector2i.ZERO
|
||
|
||
## Player-visible label. Drives Audit logs and job descriptions.
|
||
@export var label_text: String = "Torch"
|
||
|
||
# ── state ─────────────────────────────────────────────────────────────────────
|
||
|
||
## Ticks of construction work applied so far. 0..BUILD_TICKS.
|
||
var build_progress: int = 0
|
||
|
||
## True once build_progress >= BUILD_TICKS.
|
||
var _completed: bool = false
|
||
|
||
## Whether the torch is emitting light. Always true for Phase 11;
|
||
## Phase 17 may add fuel consumption or a player on/off toggle.
|
||
var _is_on: bool = true
|
||
|
||
## PointLight2D child node for Godot's visual lighting pipeline.
|
||
## Built in _ready(); enabled only once _complete() fires.
|
||
var _light: PointLight2D = null
|
||
|
||
|
||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||
|
||
func _ready() -> void:
|
||
# Bottom-anchor so Y-sort occludes pawns correctly (same pivot as Wall/Bed).
|
||
position = Vector2(
|
||
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
|
||
)
|
||
World.register_light_source(self)
|
||
_light = _build_point_light_2d()
|
||
add_child(_light)
|
||
_light.enabled = false # dark until built
|
||
queue_redraw()
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
World.unregister_light_source(self)
|
||
|
||
|
||
## One-shot initialiser. Call after add_child() so _ready() has already fired.
|
||
func setup(p_tile: Vector2i) -> void:
|
||
tile = p_tile
|
||
position = Vector2(
|
||
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
|
||
)
|
||
queue_redraw()
|
||
|
||
|
||
# ── BuildJob interface (matches Wall / Bed / Workbench shape) ─────────────────
|
||
|
||
## True while the torch still needs construction work.
|
||
func is_buildable() -> bool:
|
||
return not _completed
|
||
|
||
|
||
## Human-readable label for job descriptions and Audit logs.
|
||
func label() -> String:
|
||
return label_text
|
||
|
||
|
||
## Called by the BUILD toil in JobRunner once per sim tick while the pawn works.
|
||
## Advances build_progress and completes the torch at BUILD_TICKS.
|
||
func on_build_tick() -> void:
|
||
if _completed:
|
||
return
|
||
build_progress += 1
|
||
queue_redraw()
|
||
if build_progress >= BUILD_TICKS:
|
||
_complete()
|
||
|
||
|
||
## True once the torch has been fully built.
|
||
func is_completed() -> bool:
|
||
return _completed
|
||
|
||
|
||
## Torches are walkable — pawns step around them visually but the tile remains
|
||
## passable for pathfinding purposes.
|
||
func blocks_pathing_when_complete() -> bool:
|
||
return false
|
||
|
||
|
||
# ── light-source duck-type interface ──────────────────────────────────────────
|
||
## Used by World.is_tile_lit() and the "in darkness" thought.
|
||
## Hearth / Workbench exposes the same three functions.
|
||
|
||
## True when the torch is built and switched on.
|
||
func is_on() -> bool:
|
||
return _completed and _is_on
|
||
|
||
|
||
## The tile this light source occupies (for distance calculations).
|
||
func get_light_tile() -> Vector2i:
|
||
return tile
|
||
|
||
|
||
## The sim-side Manhattan-distance radius of this light source.
|
||
func get_light_radius() -> int:
|
||
return LIGHT_RADIUS
|
||
|
||
|
||
## Toggle the torch on/off. Phase 17 may call this from a fuel-depletion system
|
||
## or player action; safe to call any time including before _complete().
|
||
func set_on(value: bool) -> void:
|
||
_is_on = value
|
||
if _light != null:
|
||
_light.enabled = _completed and _is_on
|
||
Audit.log("light", "torch at %s set_on → %s" % [tile, value])
|
||
|
||
|
||
# ── save / load ───────────────────────────────────────────────────────────────
|
||
|
||
## Serialise all persistent state for World save (wired in Phase 16).
|
||
func to_dict() -> Dictionary:
|
||
return {
|
||
"class_id": &"torch",
|
||
"tile_x": tile.x,
|
||
"tile_y": tile.y,
|
||
"label_text": label_text,
|
||
"build_progress": build_progress,
|
||
"completed": _completed,
|
||
"is_on": _is_on,
|
||
}
|
||
|
||
|
||
## Restore from a dict produced by to_dict().
|
||
func from_dict(d: Dictionary) -> void:
|
||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||
label_text = str(d.get("label_text", "Torch"))
|
||
build_progress = int(d.get("build_progress", 0))
|
||
_completed = bool(d.get("completed", false))
|
||
_is_on = bool(d.get("is_on", true))
|
||
# _light is rebuilt in _ready() — no need to restore the node.
|
||
setup(tile)
|
||
|
||
|
||
# ── render ─────────────────────────────────────────────────────────────────────
|
||
|
||
func _draw() -> void:
|
||
# 3/4-perspective torch — fits within the tile (16×16 local box).
|
||
# Origin (0,0) = tile bottom-centre. Tile spans local Y: -16 to 0.
|
||
# Ghost (not yet built) draws at 0.4 alpha.
|
||
var alpha: float = 1.0 if _completed else 0.4
|
||
|
||
# Stick — narrow vertical brown rect, centred horizontally.
|
||
draw_rect(Rect2(Vector2(-1.0, -9.0), Vector2(2.0, 9.0)), Color(0.35, 0.22, 0.10, alpha))
|
||
|
||
# Wrapped head — slightly wider, darker, at the top of the stick.
|
||
draw_rect(Rect2(Vector2(-2.0, -13.0), Vector2(4.0, 4.0)), Color(0.20, 0.14, 0.06, alpha))
|
||
|
||
# Flame — only when built and on; two overlapping circles for depth.
|
||
if _completed and _is_on:
|
||
draw_circle(Vector2(0.0, -15.0), 2.5, Color(1.0, 0.65, 0.10, alpha))
|
||
draw_circle(Vector2(0.0, -16.0), 1.5, Color(1.0, 0.90, 0.40, alpha))
|
||
|
||
|
||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||
|
||
## Construct and return the PointLight2D that provides Godot-side visual
|
||
## lighting. The light is positioned at the flame, not the tile base.
|
||
func _build_point_light_2d() -> PointLight2D:
|
||
var p := PointLight2D.new()
|
||
p.texture = _build_radial_light_texture(LIGHT_TEXTURE_SIZE)
|
||
# Scale the texture so its radius in world-pixels matches LIGHT_RADIUS tiles.
|
||
p.texture_scale = float(LIGHT_RADIUS) * float(TILE_SIZE_PX) / float(LIGHT_TEXTURE_SIZE) * 2.0
|
||
p.color = Color(1.0, 0.85, 0.55, 1.0) # warm fire tint
|
||
p.energy = 1.2
|
||
# Offset upward so the light originates from the flame position.
|
||
p.position = Vector2(0.0, -15.0)
|
||
return p
|
||
|
||
|
||
## Build a soft radial gradient Image and return it as an ImageTexture.
|
||
## White centre, fades to transparent at the edge via smoothstep falloff.
|
||
## Called once per torch in _ready(); result is ~4 KB of VRAM at 64×64 RGBA8.
|
||
static func _build_radial_light_texture(size: int) -> Texture2D:
|
||
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
|
||
var cx: float = float(size) / 2.0
|
||
var cy: float = float(size) / 2.0
|
||
var max_r: float = float(size) / 2.0
|
||
for x in size:
|
||
for y in size:
|
||
var dx: float = float(x) - cx
|
||
var dy: float = float(y) - cy
|
||
var d: float = sqrt(dx * dx + dy * dy)
|
||
var t: float = clampf(1.0 - d / max_r, 0.0, 1.0)
|
||
# Smoothstep so the edge is soft rather than a hard circle cutoff.
|
||
var a: float = t * t * (3.0 - 2.0 * t)
|
||
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, a))
|
||
return ImageTexture.create_from_image(img)
|
||
|
||
|
||
## Called when build_progress reaches BUILD_TICKS.
|
||
func _complete() -> void:
|
||
_completed = true
|
||
if _light != null:
|
||
_light.enabled = _is_on
|
||
queue_redraw()
|
||
Audit.log("torch", "built at %s" % tile)
|
||
# Phase 13 — notify BeautySystem so nearby tile beauty scores update.
|
||
var bs = World.get("beauty_system")
|
||
if bs != null:
|
||
bs.register_furniture(self)
|
||
bs.recompute_around(tile)
|