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>
This commit is contained in:
megaproxy 2026-05-16 21:03:46 +01:00
parent 8a7584e411
commit f30c7a858b
9 changed files with 179 additions and 31 deletions

View file

@ -1 +0,0 @@
uid://dyacrro784lvo

View file

@ -0,0 +1,34 @@
class_name HyenaSpriteFrames extends RefCounted
## Builds a SpriteFrames resource for the Hyena (player-facing "hyena") entity.
##
## Atlas layout (single horizontal strip, 48×48 cells):
## Hyena_idle.png — 192×48 = 4 frames
## Hyena_walk.png — 288×48 = 6 frames
##
## 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
const FRAME_SIZE: int = 48
## Build and return a SpriteFrames with "idle" (4f, 5 fps) and "walk" (6f, 10 fps).
static func build() -> SpriteFrames:
var sf := SpriteFrames.new()
var idle_tex: Texture2D = preload("res://art/sprites/Hyena_idle.png")
var walk_tex: Texture2D = preload("res://art/sprites/Hyena_walk.png")
if sf.has_animation(&"default"):
sf.remove_animation(&"default")
_add_anim(sf, &"idle", idle_tex, 4, 5.0)
_add_anim(sf, &"walk", walk_tex, 6, 10.0)
return sf
static func _add_anim(sf: SpriteFrames, anim_name: StringName, tex: Texture2D, count: int, fps: float) -> void:
sf.add_animation(anim_name)
sf.set_animation_loop(anim_name, true)
sf.set_animation_speed(anim_name, fps)
for i in count:
var at := AtlasTexture.new()
at.atlas = tex
at.region = Rect2(i * FRAME_SIZE, 0, FRAME_SIZE, FRAME_SIZE)
sf.add_frame(anim_name, at)

View file

@ -1,6 +1,10 @@
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.
@ -21,6 +25,8 @@ 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).
@ -43,6 +49,7 @@ 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
@ -58,7 +65,6 @@ 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:
@ -69,7 +75,7 @@ func _exit_tree() -> void:
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = _tile_to_world(tile)
queue_redraw()
_mount_sprite()
Audit.log("wolf", "spawned at %s" % tile)
@ -78,6 +84,9 @@ func take_damage(amount: float) -> void:
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()
@ -85,6 +94,26 @@ 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:
@ -224,7 +253,11 @@ func from_dict(d: Dictionary) -> void:
if entry is Array and entry.size() == 2:
_path.append(Vector2i(int(entry[0]), int(entry[1])))
position = _tile_to_world(tile)
queue_redraw()
_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 ──────────────────────────────────────────────────────────────────
@ -238,6 +271,22 @@ func _process(_delta: float) -> void:
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:
@ -245,20 +294,6 @@ func _draw() -> void:
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 ─────────────────────────────────────────────────────────────────

View file

@ -124,7 +124,7 @@ static func _event_04_walls() -> EventDef:
var d := EventDef.new()
d.id = &"walls"
d.title = "Walls?"
d.body = "Sleeping under stars is romantic until the wolves arrive."
d.body = "Sleeping under stars is romantic until the hyenas arrive."
d.category = EventDef.Category.NUDGE
d.display = EventDef.Display.BANNER
d.cooldown_days = 4
@ -317,8 +317,8 @@ static func _event_12_the_wandering_healer() -> EventDef:
static func _event_13_wolves_at_the_edge() -> EventDef:
var d := EventDef.new()
d.id = &"wolves_at_the_edge"
d.title = "Wolves at the Edge"
d.body = "Wolves howl in the distance. They will be here by nightfall."
d.title = "Hyenas at the Edge"
d.body = "Hyenas howl in the distance. They will be here by nightfall."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 3
@ -337,8 +337,8 @@ static func _event_13_wolves_at_the_edge() -> EventDef:
static func _event_14_lone_wolf() -> EventDef:
var d := EventDef.new()
d.id = &"lone_wolf"
d.title = "Lone Wolf"
d.body = "A starving wolf circles your livestock."
d.title = "Lone Hyena"
d.body = "A starving hyena circles your livestock."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 3