rimlike/scenes/entities/wolf.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

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

274 lines
9.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
## Transient: set by from_dict() to the saved target's pawn_name string.
## SaveSystem._post_load_resolve_references() walks World.pawns, matches by
## pawn_name, assigns target_pawn, then clears this field.
## If the named pawn no longer exists the field stays "" and target_pawn stays
## null — the AI will pick a new target on the next sim tick.
var _pending_target_name: String = ""
# ── 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 is re-wired by SaveSystem._post_load_resolve_references() after
# all pawns are spawned. Store the name for that pass; if the pawn no longer
# exists target_pawn stays null and the AI picks a new target next tick.
target_pawn = null
_pending_target_name = str(d.get("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)