rimlike/scenes/entities/torch.gd
megaproxy 19d28ca9f8 Phase 16: Save/load full coverage + autosave + UI
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>
2026-05-11 19:24:59 +01:00

251 lines
9.5 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)