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

BIN
art/sprites/Hyena_idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bsol1xw7wnmc"
path="res://.godot/imported/Hyena_idle.png-998f97e8871db36aec2aedccbd8c28ff.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/sprites/Hyena_idle.png"
dest_files=["res://.godot/imported/Hyena_idle.png-998f97e8871db36aec2aedccbd8c28ff.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
art/sprites/Hyena_walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b7gk6ro6ilxa3"
path="res://.godot/imported/Hyena_walk.png-5ef407252c6f5cafdb6892139cc44efd.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/sprites/Hyena_walk.png"
dest_files=["res://.godot/imported/Hyena_walk.png-5ef407252c6f5cafdb6892139cc44efd.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -92,7 +92,7 @@ const TABLE: Dictionary = {
&"event.no_fire.title": "No Fire",
&"event.no_fire.body": "Without a hearth, the cold will bite by night.",
&"event.walls.title": "Walls?",
&"event.walls.body": "Sleeping under stars is romantic until the wolves arrive.",
&"event.walls.body": "Sleeping under stars is romantic until the hyenas arrive.",
&"event.spring_awakens.title": "Spring Awakens",
&"event.spring_awakens.body": "The thaw runs in every stream. Crops will grow fast now.",
&"event.summers_heat.title": "Summer's Heat",
@ -109,10 +109,10 @@ const TABLE: Dictionary = {
&"event.the_old_soldier.body": "A retired soldier offers his blade for a place by your fire. Combat 8, but old and tired.",
&"event.the_wandering_healer.title": "The Wandering Healer",
&"event.the_wandering_healer.body": "A traveling healer asks for shelter. She brings knowledge of medicine.",
&"event.wolves_at_the_edge.title": "Wolves at the Edge",
&"event.wolves_at_the_edge.body": "Wolves howl in the distance. They will be here by nightfall.",
&"event.lone_wolf.title": "Lone Wolf",
&"event.lone_wolf.body": "A starving wolf circles your livestock.",
&"event.wolves_at_the_edge.title": "Hyenas at the Edge",
&"event.wolves_at_the_edge.body": "Hyenas howl in the distance. They will be here by nightfall.",
&"event.lone_wolf.title": "Lone Hyena",
&"event.lone_wolf.body": "A starving hyena circles your livestock.",
&"event.pack_hunt.title": "Pack Hunt",
&"event.pack_hunt.body": "A hunting pack moves through the forest. They smell your colony.",
&"event.bandit_scouts.title": "Bandit Scouts",
@ -225,7 +225,7 @@ const TABLE: Dictionary = {
&"ui.day_summary.title": "Day {day} — {season}",
&"ui.day_summary.continue": "Continue",
&"ui.day_summary.pawns_alive": "Pawns alive",
&"ui.day_summary.wolves_on_map": "Wolves on map",
&"ui.day_summary.wolves_on_map": "Hyenas on map",
&"ui.day_summary.tension": "Tension",
&"ui.day_summary.tension_fmt": "{t} / 100",
# Weather labels (weather.<name>)
@ -288,9 +288,9 @@ const TABLE: Dictionary = {
&"help.priorities.heading": "Work Priorities",
&"help.priorities.body": "Each pawn has priorities (0-4) per work category. The Work matrix grid lets you tune them.\n\n0 = Off. Pawn won't do this work.\n1 = Background. Only if nothing else fits.\n2 = Low.\n3 = Normal (default).\n4 = Urgent. Drops other work to take this.\n\nNeeds (rest, eat, sleep) always run regardless - you can't starve a pawn by setting priorities.",
&"help.storyteller.heading": "Storyteller Events",
&"help.storyteller.body": "Each in-game day at 6 AM the Storyteller may roll an event. Categories include nudges (gentle hints), threats (wolves, raids), wanderers (recruit offers), seasonal beats, disease, resource booms, lore, and milestones.\n\nThreat events auto-pause the sim and show a modal with a choice. Other events show a banner that fades in a few seconds.\n\nThe Storyteller's tension rises and falls based on what happens to your colony - calmer when nothing has happened recently, escalating after kills, damage, or near-misses.",
&"help.storyteller.body": "Each in-game day at 6 AM the Storyteller may roll an event. Categories include nudges (gentle hints), threats (hyenas, raids), wanderers (recruit offers), seasonal beats, disease, resource booms, lore, and milestones.\n\nThreat events auto-pause the sim and show a modal with a choice. Other events show a banner that fades in a few seconds.\n\nThe Storyteller's tension rises and falls based on what happens to your colony - calmer when nothing has happened recently, escalating after kills, damage, or near-misses.",
&"help.tips.heading": "Early-game Tips",
&"help.tips.body": "Build at least one bed and one wall before nightfall - pawns sleep on the ground and grow tired, and wolves come at night.\n\nKeep beds indoors (under a roof). Rain and cold ruin sleep quality.\n\nPaint a stockpile early - items left on the ground decay (corpses, food) or just clutter the map.\n\nWatch tension. If it climbs, slow down and reinforce. The Storyteller is reactive - give it nothing to react to and it stays quiet.\n\nFood priority order: Meal > Bread > Vegetable / Grain / Strawberry > raw Wheat. Cook before eating raw.",
&"help.tips.body": "Build at least one bed and one wall before nightfall - pawns sleep on the ground and grow tired, and hyenas come at night.\n\nKeep beds indoors (under a roof). Rain and cold ruin sleep quality.\n\nPaint a stockpile early - items left on the ground decay (corpses, food) or just clutter the map.\n\nWatch tension. If it climbs, slow down and reinforce. The Storyteller is reactive - give it nothing to react to and it stays quiet.\n\nFood priority order: Meal > Bread > Vegetable / Grain / Strawberry > raw Wheat. Cook before eating raw.",
# Phase 19 — Onboarding section in SettingsMenu.
&"ui.settings.section.onboarding": "Onboarding",
&"ui.settings.show_hints": "Show hints (first session)",

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