rimlike/scenes/ai/wolf_spawner.gd
megaproxy b9093dd24b Phase 17: Touch UX (PawnDetail+BuildDrawer+WorkMatrix+AlertsLog+Settings)
Three-agent fan-out shipping the major touch UI surfaces. Opus pre-wrote
6 EventBus signals (pawn_selected/deselected, pawn_priority_changed,
alert_added, request_wolf_spawn, day_ended) + Pawn.work_priorities
Dictionary stub before dispatch. Pattern proven across Phases 12-17.

Pawn detail + Settings (Agent A):
- scenes/ui/pawn_detail_panel.gd — right-side CanvasLayer (layer 18),
  ~360px wide, opens on EventBus.pawn_selected. Renders portrait,
  HP/Hunger/Sleep bars with threshold colors, current job, mood +
  sulking, statuses, top 5 mood thoughts, full skill table,
  read-only work-priorities row. Live-refreshes each sim tick.
- scenes/ui/settings_menu.gd — modal CanvasLayer (layer 26), opened
  via Settings button. Auto-pause toggles (Threat/Wanderer/Pawn-Down/
  Modal), audio sliders (stubs for Phase 18), accessibility checkboxes.
  Persists via GameState.apply_settings.
- scenes/world/selection.gd — extended to emit pawn_selected/deselected
  through EventBus on tap.

Build drawer + 12 new Designation tools (Agent B):
- scenes/ui/build_drawer.gd — bottom-sheet CanvasLayer (layer 16) with
  4 tabs (Designate/Build/Stockpile/Cancel) + FAB ⊕ open button.
  Each tab has HFlowContainer of 80×80 buttons with procedural colored
  icons + label. Tap → Designation.set_active_tool + alert + auto-close.
- Designation: added TOOL_CHOP, TOOL_MINE, TOOL_BUILD_CRATE,
  TOOL_BUILD_BED, TOOL_BUILD_TORCH, 5× TOOL_BUILD_WORKBENCH_* variants,
  TOOL_PAINT_STOCKPILE. Plus tool_material override for wall/floor.
- World._on_designation_added: extended dispatch for all 12 new tools;
  added _spawn_workbench() helper for the 5 bench kinds.

Work matrix + Alerts log + Decision refactor + Wolf signal (Agent C):
- scenes/ai/decision.gd: Layer 4 now filters by pawn.work_priorities
  (0=OFF skip, sort by level ascending with provider.priority tiebreak).
  NEEDS_CATEGORIES (rest/eat/sleep) bypass the filter — a pawn can
  never starve from misconfiguration. Audit log prefixes work decisions
  with (pri=N).
- scenes/ui/work_priority_matrix.gd — CanvasLayer (layer 17) bottom-sheet
  grid: rows=pawns × cols=8 work categories. Each cell tap-cycles
  1→2→3→4→0→1, color-coded (red/orange/yellow/blue/gray). Writes back
  to pawn.work_priorities + emits pawn_priority_changed.
- scenes/ui/alerts_log.gd — CanvasLayer (layer 19) ring buffer 50
  entries. Newest first, severity icon (info/warn/danger), Day HH:MM
  timestamp, Go-there camera pan. Listens to alert_added +
  storyteller_event_fired + day_ended.
- EventBus.request_wolf_spawn wired end-to-end: EventCatalog
  _spawn_wolves emits; WolfSpawner._on_request_wolf_spawn force-spawns
  bypassing the darkness/cooldown gates.
- Clock emits EventBus.day_ended(summary) at dusk→night transition.

Top bar buttons added in order: ‖ / 1× / 5× / 12× / Save / Load /
Settings / Build / Work / Log[N]. Plus the ⊕ FAB at bottom-right.

MCP runtime verified all 4 surfaces via screenshot:
- PawnDetailPanel: Bram shows Crafting=8 / Cooking=2 / Manual=0
  matching seed; bars green; Mood: 50; work-priorities readout
- BuildDrawer: 4 tabs visible, Designate tab shows Chop/Mine/Dig grave/
  No roof buttons with procedural icons
- WorkPriorityMatrix: 3 pawns × 8 categories, all '3' (NORMAL default)
  cells in yellow, tap-to-cycle ready
- AlertsLog: 4 entries — red 'Wolf pack approaching!' danger, blue
  'Bram is at the cabin' info, yellow 'Test alert' warn, blue 'Spring
  Awakens' from boot storyteller roll. Go-there button per entry.

Mouse drag-paint works as-is (user noted). Existing
Selection/Designation _unhandled_input handles drag.

Deferred to Phase 17.5 polish:
- Per-pawn/per-job view layers on the matrix
- Stockpile 4×4 chip filter UI (paint creates 1×1 zones today)
- Bill UI for workbenches (programmatic only today)
- 'No stockpile accepts X' / 'Bill blocked' alert emit wiring
- DaySummaryCard visual (signal emits today, no card UI)
- Wanderer recruit UI, resource buff system

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:45:35 +01:00

119 lines
4.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 WolfSpawner extends Node
## Storyteller-side spawner for wolf raid events.
##
## Spawn trigger (Phase 10): full night (darkness_factor >= 0.8) AND off cooldown.
## Season-weighted spawning (design.md "Wolf spawn") is deferred to Phase 12
## when WeatherSystem / SeasonSystem land.
##
## WolfSpawner is a Node child of the World scene — not an autoload.
## Wolf scenes are add_child()'d to get_parent() (the World scene root) so
## wolves share the same node tree as all other entities.
##
## Pack size is PACK_MINPACK_MAX for Phase 10 demo. design.md target is 14;
## widen the range in Phase 17 once the combat spike validates feel.
const PACK_MIN: int = 1
const PACK_MAX: int = 2 # Phase 10 demo; design.md target 14 for Phase 17.
## Minimum sim ticks between raids. 4800 ticks = 1 in-game day at 20 Hz.
## Prevents stacked raids in consecutive night phases.
const RAID_COOLDOWN_TICKS: int = 4800
## How far from each map edge to allow spawn tiles (avoids corner/boundary weirdness).
const MAP_EDGE_BLEED: int = 2
## Preloaded Wolf scene. Mirrors the pattern used by Tree/Item entities.
const WOLF_SCENE: PackedScene = preload("res://scenes/entities/wolf.tscn")
## Sim-tick number of the last raid this spawner triggered.
## Initialised to -RAID_COOLDOWN_TICKS so the first eligible night is fair game.
var _last_raid_tick: int = -RAID_COOLDOWN_TICKS
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
# Phase 17 — EventCatalog threat events bypass darkness/cooldown gates and
# force-spawn wolves immediately via this signal.
if EventBus.has_signal("request_wolf_spawn"):
EventBus.request_wolf_spawn.connect(_on_request_wolf_spawn)
func _on_sim_tick(n: int) -> void:
# Gate 1: only spawn during deep night (darkness >= 0.8 means past dusk ramp).
# Clock.darkness_factor() returns 1.0 at full night, 0.8 at ~90% through the dusk ramp.
if Clock.darkness_factor() < 0.8:
return
# Gate 2: cooldown — at least one in-game day between raids.
if n - _last_raid_tick < RAID_COOLDOWN_TICKS:
return
_trigger_raid(n)
func _trigger_raid(current_tick: int) -> void:
_last_raid_tick = current_tick
var pack_size := randi_range(PACK_MIN, PACK_MAX)
var spawn_tiles := _pick_spawn_tiles(pack_size)
for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w)
w.setup(spawn_tile)
Audit.log("wolf", "RAID: %d wolf(ves) spawned at %s" % [pack_size, spawn_tiles])
## Phase 17 — force-spawn `count` wolves immediately, bypassing darkness and
## cooldown gates. Used by EventCatalog threat events (wolves_at_the_edge,
## lone_wolf, pack_hunt) which fire at narrative moments regardless of time.
## _last_raid_tick is NOT updated here — a forced narrative raid does not reset
## the night-attack cooldown, so organic raids can still follow.
func _on_request_wolf_spawn(count: int) -> void:
var spawn_tiles := _pick_spawn_tiles(count)
for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w)
w.setup(spawn_tile)
Audit.log("wolf", "FORCED RAID (event): %d wolf(ves) spawned at %s" % [count, spawn_tiles])
func _pick_spawn_tiles(count: int) -> Array[Vector2i]:
## Choose a random map edge, then return `count` tiles clustered near a
## random anchor on that edge. All tiles are inside MAP_EDGE_BLEED to avoid
## corner/boundary edge cases with the pathfinder grid bounds.
##
## Map size matches World.MAP_SIZE_TILES (80×80 in Phase 1).
## Duck-typed access to World.MAP_SIZE_TILES would require it to be declared
## there; using the literal constant is safe for MVP and matches the pattern
## used in WolfSpawner's Phase 10 scope. Phase 16 or 17 can wire this to
## World.MAP_SIZE_TILES when that const lands on the autoload.
const MAP_W: int = 80
const MAP_H: int = 80
var side: int = randi() % 4 # 0 = top, 1 = right, 2 = bottom, 3 = left
var anchor: Vector2i
match side:
0: # Top edge
anchor = Vector2i(
randi_range(MAP_EDGE_BLEED, MAP_W - MAP_EDGE_BLEED - 1),
MAP_EDGE_BLEED
)
1: # Right edge
anchor = Vector2i(
MAP_W - MAP_EDGE_BLEED - 1,
randi_range(MAP_EDGE_BLEED, MAP_H - MAP_EDGE_BLEED - 1)
)
2: # Bottom edge
anchor = Vector2i(
randi_range(MAP_EDGE_BLEED, MAP_W - MAP_EDGE_BLEED - 1),
MAP_H - MAP_EDGE_BLEED - 1
)
_: # Left edge (side == 3)
anchor = Vector2i(
MAP_EDGE_BLEED,
randi_range(MAP_EDGE_BLEED, MAP_H - MAP_EDGE_BLEED - 1)
)
# Cluster wolves horizontally from the anchor; they start packed together.
var tiles: Array[Vector2i] = []
for i in count:
tiles.append(anchor + Vector2i(i, 0))
return tiles