rimlike/scenes/ai/wolf_spawner.gd
megaproxy bba1ce4334 Phase 17/18 closure: stockpile filter UI + day summary + atmospheric audio
Three-agent fan-out (gdscript-refactor x3) closing deferred polish:

- Stockpile chip filter UI: new StockpilePanel (layer 18, right-anchored,
  mirrors WorkbenchPanel). 5-priority segmented control + 21-chip 4-col
  filter grid using Item.ALL_TYPES; wildcard (empty accepted_types) shows
  all chips checked with 'All' hint, first explicit pick switches to
  explicit-list mode. Selection chain extended to pawn → workbench →
  stockpile with mutual exclusion. 12 ui.stockpile.* + 13 item.* keys.

- DaySummaryCard: layer-19 modal auto-opens at dusk→night via day_ended,
  auto-pauses sim, shows day+season header, weather row, stats grid with
  green/yellow/red tension bar, Continue dismiss + backdrop tap.
  Settings 'Show end-of-day summary' toggle persists via GameState.

- Atmospheric audio: rain ambient loop (Cozy Melodies Pack 6) on
  weather_changed rain/storm with 0.5s fade-out on clear; thunder sting
  (Magic and Spells 6) on rain→storm transition; raid warning sting
  (Sword Pack 1, 'blades drawn') on EventBus.wolf_spawned. All on SFX
  bus — inherits existing slider + suspend mute.

Contracts pre-written before fan-out: EventBus.stockpile_selected /
stockpile_deselected / wolf_spawned signals; WolfSpawner._trigger_raid
+ _on_request_wolf_spawn now emit wolf_spawned with the spawned array.

MCP runtime verified: StockpilePanel opens with 21 chips, DaySummaryCard
renders weather row + tension bar + auto-pause, rain_player.playing=true
on weather_changed(rain), all three new SFX keys in Audio.SFX_FILES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:20:40 +01:00

125 lines
4.7 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)
var spawned: Array = []
for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w)
w.setup(spawn_tile)
spawned.append(w)
EventBus.wolf_spawned.emit(spawned)
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)
var spawned: Array = []
for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w)
w.setup(spawn_tile)
spawned.append(w)
EventBus.wolf_spawned.emit(spawned)
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