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

224 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 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
# ── 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)