rimlike/scenes/entities/crop.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

212 lines
7.9 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 Crop extends Node2D
## Crop entity — a farm plant tile that grows through stages and is harvested by a pawn.
##
## Growth model (docs/implementation.md Phase 7):
## SOWN → GROWING_1 → GROWING_2 → GROWING_3 → READY, each stage taking STAGE_TICKS sim ticks.
## At 20 Hz, 200 ticks ≈ 10 sim seconds ≈ 2 in-game minutes at Fast speed.
## "No growth indoors" rule (docs/design.md) lands in Phase 13 when the Roof flag
## system is fully wired; for now crops always grow.
##
## A PlantProvider creates a Job whose INTERACT toil calls on_harvest_tick() or
## on_sow_tick() once per sim tick via JobRunner. Both are single-tick completions.
## The INTERACT toil finishes when is_harvestable() / is_sowable() returns false.
##
## World registration (World.register_crop / World.unregister_crop) is called here.
const TILE_SIZE_PX: int = 16
## Phase 7 ships wheat and potato. Phase 17 expands (berry, hop) per design.md.
const KIND_WHEAT: StringName = &"wheat"
const KIND_POTATO: StringName = &"potato"
## Sim ticks per growth stage. 200 ticks × 4 stages = 800 total.
## At 20 Hz × 5× speed = 100 ticks/sec → 8 real seconds per stage, 32 seconds full grow.
const STAGE_TICKS: int = 200
const STAGE_COUNT: int = 4
enum Stage { TILLED, SOWN, GROWING_1, GROWING_2, GROWING_3, READY }
@export var crop_kind: StringName = KIND_WHEAT
@export var tile: Vector2i = Vector2i.ZERO
var stage: Stage = Stage.SOWN
## Progress within the current growth stage; 0..STAGE_TICKS.
var stage_progress: int = 0
# Phase 13 — "no growth indoors" rule. True once we've logged the first
# indoor detection for this crop instance so we don't flood the audit log.
var _logged_indoor: bool = false
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
position = _tile_to_world(tile)
World.register_crop(self)
EventBus.sim_tick.connect(_on_sim_tick)
queue_redraw()
func _exit_tree() -> void:
World.unregister_crop(self)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() has already fired.
func setup(p_tile: Vector2i, p_kind: StringName, p_stage: Stage = Stage.SOWN) -> void:
tile = p_tile
crop_kind = p_kind
stage = p_stage
stage_progress = 0
position = _tile_to_world(tile)
queue_redraw()
Audit.log("crop", "spawned %s at %s (stage=%s)" % [crop_kind, tile, Stage.keys()[stage]])
## True when this crop can be harvested by a pawn.
func is_harvestable() -> bool:
return stage == Stage.READY
## True when this crop can be sown by a pawn (bare tilled soil, no plant yet).
func is_sowable() -> bool:
return stage == Stage.TILLED
## Called by the INTERACT toil in JobRunner once per sim tick while a pawn harvests.
## Single-tick harvest: drops an output Item and resets to TILLED (re-sowable).
func on_harvest_tick() -> void:
if not is_harvestable():
return
var item_type := _harvest_output_for(crop_kind)
var it: Item = ITEM_SCENE.instantiate()
get_parent().add_child(it)
it.setup(item_type, 1, tile)
stage = Stage.TILLED
stage_progress = 0
Audit.log("crop", "harvested %s at %s%s" % [crop_kind, tile, item_type])
queue_redraw()
## Called by the INTERACT toil in JobRunner once per sim tick while a pawn sows.
## Single-tick sow: transitions TILLED → SOWN so growth begins on the next sim tick.
func on_sow_tick() -> void:
if not is_sowable():
return
stage = Stage.SOWN
stage_progress = 0
Audit.log("crop", "sown %s at %s" % [crop_kind, tile])
queue_redraw()
# ── growth ────────────────────────────────────────────────────────────────────
func _on_sim_tick(_n: int) -> void:
if stage == Stage.READY or stage == Stage.TILLED:
return
# Phase 13 — crops don't grow indoors (no sunlight under a roof).
# World.is_indoor() returns false while RoomDetector has not yet fired, so
# outdoor crops planted during boot are unaffected.
if World.is_indoor(tile):
if not _logged_indoor:
Audit.log("crop", "%s at %s won't grow (indoor)" % [crop_kind, tile])
_logged_indoor = true
return
# Crop has moved outdoors or was never indoors — reset the log flag so a
# future re-roofing produces another audit line.
_logged_indoor = false
stage_progress += 1
if stage_progress >= STAGE_TICKS:
stage_progress = 0
stage = (int(stage) + 1) as Stage
queue_redraw()
if stage == Stage.READY:
Audit.log("crop", "%s ready at %s" % [crop_kind, tile])
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"crop",
"tile_x": tile.x,
"tile_y": tile.y,
"crop_kind": String(crop_kind),
"stage": int(stage),
"stage_progress": stage_progress,
}
## Returns a plain Dictionary spec for World to instantiate from.
## Crops cannot reconstruct themselves standalone — they need a parent in the
## scene tree. World adds the node, then calls setup() from the returned dict.
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"crop_kind": StringName(d.get("crop_kind", "wheat")),
"stage": int(d.get("stage", Stage.SOWN)),
"stage_progress": int(d.get("stage_progress", 0)),
}
# ── render ────────────────────────────────────────────────────────────────────
func _draw() -> void:
# Tilled-soil base: a small dark-earth square.
var soil_color := Color(0.32, 0.20, 0.10)
var soil_dark := Color(0.22, 0.14, 0.06)
draw_rect(Rect2(Vector2(-7.0, -7.0), Vector2(14.0, 14.0)), soil_color)
draw_rect(Rect2(Vector2(-7.0, -7.0), Vector2(14.0, 14.0)), soil_dark, false, 1.0)
if stage == Stage.TILLED:
return # Bare soil — no plant drawn.
# stage_idx: 0 = SOWN, 4 = READY
var stage_idx := int(stage) - int(Stage.SOWN)
var height: float = lerp(2.0, 12.0, float(stage_idx) / float(STAGE_COUNT))
var plant_color := _plant_color_for(crop_kind)
# Stem
draw_rect(Rect2(Vector2(-2.0, 5.0 - height), Vector2(4.0, height)), plant_color)
# Foliage circle grows in from GROWING_2 onward
if stage_idx >= 2:
draw_circle(Vector2(0.0, 5.0 - height), 3.0 + float(stage_idx), plant_color)
# Ready accent — grain head or potato cap
if stage == Stage.READY:
draw_circle(Vector2(0.0, 5.0 - height), 2.0, _ready_accent_for(crop_kind))
# ── helpers ───────────────────────────────────────────────────────────────────
func _harvest_output_for(kind: StringName) -> StringName:
match kind:
KIND_WHEAT: return Item.TYPE_GRAIN
KIND_POTATO: return Item.TYPE_VEGETABLE
_: return Item.TYPE_VEGETABLE # fallback
func _plant_color_for(kind: StringName) -> Color:
match kind:
KIND_WHEAT: return Color(0.50, 0.65, 0.20) # bright green sprout
KIND_POTATO: return Color(0.30, 0.55, 0.20) # darker green
_: return Color(0.40, 0.60, 0.20)
func _ready_accent_for(kind: StringName) -> Color:
match kind:
KIND_WHEAT: return Color(0.95, 0.85, 0.20) # golden grain head
KIND_POTATO: return Color(0.95, 0.60, 0.30) # orange potato cap
_: return Color(1.0, 0.4, 0.4)
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)