rimlike/scenes/ai/wolf_spawner.gd
megaproxy 1b6ad2bcc6 Phase 9+10: Status/Doctor/Medical bed + Wolves/WolfSpawner
The 'drama pair' shipped together via 3-agent fan-out.

Phase 9 — Status effects + Medicine:
- Status data class (PERSISTENT/EVENT, severity stacks max=3) + StatusCatalog
  (Bleeding ticks HP loss; Downed = incapacitated)
- Pawn HP (100 max, 30 downed threshold, 50 revive threshold), take_damage,
  heal, add_status/remove_status_by_id, is_downed/is_incapacitated, downed
  visual (body rotated 90° + desaturated)
- DoctorProvider (priority 9, highest) — scans World.pawns for nearest downed
  pawn, finds medical bed (or any bed fallback), emits 4-toil job:
  walk_to_patient → rescue → walk_to_bed → treat
- Bed.is_medical with red-cross marker draw on pillow; round-trips save
- KIND_RESCUE + KIND_TREAT toils + JobRunner _tick_rescue/_tick_treat
  (snap-to-bed on first treat tick, +0.5 hp/tick, bleed cure at 100-tick
  intervals; done at HP≥50 + no bleeding, 600-tick timeout)
- EventBus: pawn_took_damage, pawn_status_added, pawn_status_removed

Phase 10 — Combat + Wolves (wolf-first slice):
- Wolf entity (Node2D, 4-state APPROACH/ENGAGE/FLEE/DEAD, procedural
  canine sprite with red glowing eyes, 40 HP)
- Two-roll combat: 70% hit + 50% chance to apply Bleeding(1) on hit
- WolfSpawner — triggers at Clock.darkness_factor()≥0.8 with 1-in-game-day
  cooldown, packs of 1–2 at random map-edge cluster
- World.wolves registry + register_wolf/unregister_wolf

Integration: world.tscn load_steps 15→17 with DoctorProvider + WolfSpawner
nodes. world.gd registers doctor at top of provider list (priority 9 >
sleep 8 > eat 7 > construction 6 > chop≈plant 5 > mine≈craft 4 > haul 3
> rest 0). Middle bed at (47,24) marked is_medical=true.

MCP runtime verified: Bram took 75 dmg + Bleeding(2) → Downed (hp 25) →
Edda + Cora both volunteered doctor job → walked to patient → carried to
medical bed → treated → Bram healed to 94.2 hp, statuses cleared, back to
work. Wolf raid at day 3 22:00 fired; 4 wolves alive across raid cycles
by day 4 01:51. Screenshots confirm red-cross medical bed and wolf
silhouettes at night.

Phase 10 deliberately partial: wolf-side combat ships, pawn-side
weapons/armor/cover/friendly-fire deferred — full chain
(wolf→bites→pawn→bleeds→doctor) awaits player weapons.
Bleed-out timer at demo value (1200) vs design value (432000 = 6 in-game
hours) — documented in status_catalog.gd for first time-balance pass.

Delegation: Agent A (status + pawn HP), Agent B (doctor + treatment),
Agent C (wolf + spawner) — all Sonnet gdscript-refactor; integration on
Opus.

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

101 lines
3.6 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)
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])
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