rimlike/scenes/entities/wolf.gd
megaproxy f30c7a858b reskin wolf as hyena — visual + lean-in flavor
Replaces the procedural brown-rectangle "wolf" with the CraftPix Free
Desert Enemy Sprite Sheets hyena. 48×48, side-view, 2-direction via
horizontal flip. AnimatedSprite2D mounted in Wolf.setup() and
Wolf.from_dict() (same pattern as pawn reskin Slice 1). Anims: idle
(4f @ 5fps) + walk (6f @ 10fps). Attack/hurt/death anims skipped for
MVP scope.

Player-facing copy renamed wolf→hyena in strings.gd (6 entries) and
event_catalog.gd (3 EventDef title/body fields). Internal identifiers
(class Wolf, World.wolves, EventBus.wolf_spawned, save class_id
&"wolf", event IDs like &"lone_wolf") stay the same for save compat
— see header comment in wolf.gd.

MCP runtime verified: hyena AnimatedSprite2D mounted on spawn, idle
anim plays, storyteller modal renders "Lone Hyena — A starving hyena
circles your livestock."

Sprites: CraftPix Free Desert Enemy Sprite Sheets.
License: CraftPix Free (commercial OK, attribution appreciated).
https://free-game-assets.itch.io/free-enemy-sprite-sheets-pixel-art

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

309 lines
11 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.
##
## Player-facing copy renames this entity to "hyena" (see strings.gd, event_catalog.gd
## labels). Internal class + registry identifiers remain "wolf"/"Wolf" for save
## compatibility — class_id: &"wolf" in saves, World.wolves registry, EventBus.wolf_spawned.
##
## 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
const _HYENA_SPRITE_FRAMES = preload("res://scenes/entities/hyena_sprite_frames.gd")
# ── 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
var _sprite: AnimatedSprite2D = null
## 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)
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)
_mount_sprite()
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)
# Hide sprite; dead X marker is drawn by _draw() on the Node2D.
if _sprite != null:
_sprite.visible = false
queue_redraw()
func is_dead() -> bool:
return state == State.DEAD
## Creates (or re-creates) the AnimatedSprite2D child. Idempotent — frees any
## prior sprite so setup() and from_dict() can both call it safely.
func _mount_sprite() -> void:
if _sprite != null:
_sprite.queue_free()
_sprite = null
_sprite = AnimatedSprite2D.new()
_sprite.sprite_frames = _HYENA_SPRITE_FRAMES.build()
# 48×48 sprite centred on the tile origin: shift up so the feet land at the
# tile bottom edge (tile bottom = +8 px from centre → feet need to be at +8
# → sprite centre at +8 24 = 16 px).
_sprite.offset = Vector2(0.0, -16.0)
# Y-sort: sit on the same plane as pawns (z_index 0, use y_sort_enabled on
# the parent World node). Slightly behind pawns so they occlude the hyena
# when occupying the same tile.
_sprite.z_index = -1
_sprite.play(&"idle")
add_child(_sprite)
# ── 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)
_mount_sprite()
# If dead on load, hide sprite immediately (X marker drawn by _draw()).
if state == State.DEAD and _sprite != null:
_sprite.visible = false
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)
# Anim switching — avoid restarting the same anim each frame.
if _sprite == null:
return
var moving: bool = not _path.is_empty()
var target_anim := &"walk" if moving else &"idle"
if _sprite.animation != target_anim:
_sprite.play(target_anim)
# Facing — 2-direction horizontal flip; preserve last facing on pure vertical.
if not _path.is_empty():
var delta_x: int = _path[0].x - tile.x
if delta_x > 0:
_sprite.flip_h = false # moving right → face right (default)
elif delta_x < 0:
_sprite.flip_h = true # moving left → face left
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)
# ── 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)