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

265 lines
9.4 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 Wolf extends Node2D
## Wolf entity — hostile animal with a 4-state AI state machine.
##
## State machine (docs/architecture.md "Wolf AI"):
## APPROACH → walk toward nearest non-downed pawn within sight radius.
## ENGAGE → attack adjacent pawn; 70% hit chance; 50% chance to apply Bleeding.
## FLEE → Phase 10 simplification: wolves never flee (fight to the death).
## Phase 17 may add flee-when-low-hp behaviour.
## DEAD → HP reached 0; renders an X marker.
##
## Combat tunables are Phase 10 placeholders; Phase 20 will tune them against
## real pawn stats. Hit math matches docs/architecture.md "Hit / damage resolution"
## (simplified — no weapon/armor/cover modifiers until Phase 17).
##
## Registration follows the same pattern as Tree and Rock: _ready() calls
## World.register_wolf(), _exit_tree() calls World.unregister_wolf().
## Pawns are referenced by duck typing only (no `Pawn` class_name) so the
## autoload-ordering window from Phase 2/3 cannot bite here.
const TILE_SIZE_PX: int = 16
## Wolves move slightly faster than pawns (pawn STEP_TICKS = 10).
const STEP_TICKS: int = 8
# ── combat tunables (Phase 10 placeholders; tune Phase 20) ──────────────────
const ATTACK_DAMAGE: float = 12.0
## 1.5 in-game seconds between attacks at 1× (30 ticks × 50 ms).
const ATTACK_COOLDOWN_TICKS: int = 30
## How many tiles away a wolf can "see" a pawn.
const SIGHT_RADIUS: int = 12
const HP_MAX: float = 40.0
## Probability (01) that a successful hit also inflicts Bleeding status.
const BLEEDING_CHANCE: float = 0.5
# ── state machine ────────────────────────────────────────────────────────────
enum State { APPROACH, ENGAGE, FLEE, DEAD }
@export var tile: Vector2i = Vector2i.ZERO
var state: State = State.APPROACH
var hp: float = HP_MAX
## Current pawn target; duck-typed (exposes .tile, .pawn_name, .is_downed(), .take_damage(), .add_status()).
var target_pawn = null
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _attack_cooldown: int = 0
# ── lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void:
position = _tile_to_world(tile)
World.register_wolf(self)
EventBus.sim_tick.connect(_on_sim_tick)
queue_redraw()
func _exit_tree() -> void:
World.unregister_wolf(self)
## One-shot initialiser. Call after add_child() so _ready() has already fired.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = _tile_to_world(tile)
queue_redraw()
Audit.log("wolf", "spawned at %s" % tile)
func take_damage(amount: float) -> void:
hp = maxf(0.0, hp - amount)
if hp <= 0.0 and state != State.DEAD:
state = State.DEAD
Audit.log("wolf", "wolf at %s killed" % tile)
queue_redraw()
func is_dead() -> bool:
return state == State.DEAD
# ── state machine tick ──────────────────────────────────────────────────────
func _on_sim_tick(_n: int) -> void:
if state == State.DEAD:
return
if _attack_cooldown > 0:
_attack_cooldown -= 1
match state:
State.APPROACH: _tick_approach()
State.ENGAGE: _tick_engage()
State.FLEE: _tick_flee()
func _tick_approach() -> void:
# Find nearest non-downed pawn within sight radius.
if target_pawn == null or target_pawn.is_downed():
target_pawn = _find_target()
if target_pawn == null:
return # No eligible target; wolf stands still.
# (Re-)plan path whenever we acquire a new target.
if World.pathfinder != null:
_path = World.pathfinder.find_path(tile, target_pawn.tile)
_advance_walk()
# Switch to ENGAGE when adjacent to the target.
if target_pawn != null and _manhattan(tile, target_pawn.tile) <= 1:
state = State.ENGAGE
func _tick_engage() -> void:
# Re-acquire if current target was downed or lost.
if target_pawn == null or target_pawn.is_downed():
target_pawn = _find_target()
if target_pawn == null:
return
state = State.APPROACH
return
# Move toward target if it has drifted more than 1 tile away.
if _manhattan(tile, target_pawn.tile) > 1:
state = State.APPROACH
return
# Attack if off cooldown.
if _attack_cooldown == 0:
_attack(target_pawn)
_attack_cooldown = ATTACK_COOLDOWN_TICKS
func _tick_flee() -> void:
# Phase 10 simplification: wolves fight to the death — FLEE is a no-op.
# Phase 17 may add flee-when-low-hp via: if hp < HP_MAX * 0.30 → flee logic.
pass
func _attack(target) -> void:
# Simple two-outcome hit roll (70% base hit chance per docs/architecture.md
# "Hit / damage resolution"). Phase 17 will add weapon/armor/cover modifiers.
var hit_roll := randf()
if hit_roll < 0.7:
target.take_damage(ATTACK_DAMAGE, "wolf")
Audit.log("wolf", "wolf hit %s for %.1f" % [target.pawn_name, ATTACK_DAMAGE])
# 50% chance to inflict Bleeding status (design.md "Combat" + Phase 9 StatusCatalog).
if randf() < BLEEDING_CHANCE:
target.add_status(StatusCatalog.bleeding(1))
Audit.log("wolf", "wolf applied Bleeding to %s" % target.pawn_name)
else:
Audit.log("wolf", "wolf missed %s" % target.pawn_name)
func _find_target():
## Returns the nearest non-downed pawn within SIGHT_RADIUS tiles (Manhattan),
## or null if none exists. Duck-typed — no Pawn class_name dependency.
var best = null
var best_dist: int = SIGHT_RADIUS + 1 # exclusive upper bound
for p in World.pawns:
if p.is_downed():
continue
var d := _manhattan(tile, p.tile)
if d > SIGHT_RADIUS:
continue
if d < best_dist:
best_dist = d
best = p
return best
func _advance_walk() -> void:
if _path.is_empty():
return
_step_progress += 1.0 / float(STEP_TICKS)
if _step_progress >= 1.0:
tile = _path[0]
_path.remove_at(0)
_step_progress = 0.0
# ── save / load ──────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
# target_pawn is stored as a name string so the loader can re-resolve it
# against World.pawns without a live Node reference.
var target_name: String = ""
if target_pawn != null and target_pawn.get("pawn_name") != null:
target_name = str(target_pawn.pawn_name)
var path_data: Array = []
for v in _path:
path_data.append([v.x, v.y])
return {
"class_id": &"wolf",
"tile_x": tile.x,
"tile_y": tile.y,
"hp": hp,
"state": int(state),
"step_progress": _step_progress,
"attack_cooldown": _attack_cooldown,
"target_pawn_name": target_name,
"path": path_data,
}
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
hp = clampf(float(d.get("hp", HP_MAX)), 0.0, HP_MAX)
state = int(d.get("state", State.APPROACH)) as State
_step_progress = float(d.get("step_progress", 0.0))
_attack_cooldown = int(d.get("attack_cooldown", 0))
# target_pawn: re-resolved by the loader after all pawns are restored.
# Store the name in a temporary string; caller sets target_pawn post-load.
target_pawn = null # caller must re-resolve from "target_pawn_name"
_path.clear()
for entry in d.get("path", []):
if entry is Array and entry.size() == 2:
_path.append(Vector2i(int(entry[0]), int(entry[1])))
position = _tile_to_world(tile)
queue_redraw()
# ── render ──────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if state == State.DEAD:
return
# Lerp render position between current tile and next tile in the path.
var from_w := _tile_to_world(tile)
var to_t := _path[0] if not _path.is_empty() else tile
var to_w := _tile_to_world(to_t)
position = from_w.lerp(to_w, _step_progress)
func _draw() -> void:
if state == State.DEAD:
# X marker — dark red crosshatch so the corpse is visible but subdued.
var x_color := Color(0.30, 0.10, 0.10, 0.8)
draw_line(Vector2(-6, -6), Vector2(6, 6), x_color, 2.0)
draw_line(Vector2(6, -6), Vector2(-6, 6), x_color, 2.0)
return
# Dark-brown canine body (12×6 rect) plus a slightly lighter snout pellet.
var body_col := Color(0.25, 0.22, 0.20, 1.0)
var snout_col := Color(0.18, 0.15, 0.13, 1.0)
# Body — horizontally elongated, centered slightly left of tile center.
draw_rect(Rect2(Vector2(-7.0, -2.0), Vector2(12.0, 6.0)), body_col)
# Snout — small block protruding to the right.
draw_rect(Rect2(Vector2(4.0, -3.0), Vector2(5.0, 4.0)), snout_col)
# Eye glow — single red dot; signals hostility at a glance.
draw_circle(Vector2(6.0, -1.5), 0.7, Color(0.95, 0.30, 0.20, 0.95))
# Legs — 4 short downward marks below the body.
for x_off in [-5.0, -1.0, 2.0, 5.0]:
draw_rect(Rect2(Vector2(x_off, 4.0), Vector2(1.5, 3.0)), body_col)
# ── helpers ─────────────────────────────────────────────────────────────────
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
)
func _manhattan(a: Vector2i, b: Vector2i) -> int:
return abs(a.x - b.x) + abs(a.y - b.y)