diff --git a/art/sprites/Hyena_idle.png b/art/sprites/Hyena_idle.png new file mode 100644 index 0000000..7cf400c Binary files /dev/null and b/art/sprites/Hyena_idle.png differ diff --git a/art/sprites/Hyena_idle.png.import b/art/sprites/Hyena_idle.png.import new file mode 100644 index 0000000..59b9b0a --- /dev/null +++ b/art/sprites/Hyena_idle.png.import @@ -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 diff --git a/art/sprites/Hyena_walk.png b/art/sprites/Hyena_walk.png new file mode 100644 index 0000000..0e2561e Binary files /dev/null and b/art/sprites/Hyena_walk.png differ diff --git a/art/sprites/Hyena_walk.png.import b/art/sprites/Hyena_walk.png.import new file mode 100644 index 0000000..3c92637 --- /dev/null +++ b/art/sprites/Hyena_walk.png.import @@ -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 diff --git a/autoload/strings.gd b/autoload/strings.gd index 32e3141..9b40b96 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -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.) @@ -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)", diff --git a/scenes/ai/rest_provider.gd.uid b/scenes/ai/rest_provider.gd.uid deleted file mode 100644 index 41c1462..0000000 --- a/scenes/ai/rest_provider.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dyacrro784lvo diff --git a/scenes/entities/hyena_sprite_frames.gd b/scenes/entities/hyena_sprite_frames.gd new file mode 100644 index 0000000..8b24a64 --- /dev/null +++ b/scenes/entities/hyena_sprite_frames.gd @@ -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) diff --git a/scenes/entities/wolf.gd b/scenes/entities/wolf.gd index ba65120..297e2b1 100644 --- a/scenes/entities/wolf.gd +++ b/scenes/entities/wolf.gd @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/scenes/storyteller/event_catalog.gd b/scenes/storyteller/event_catalog.gd index ef12d3a..190825e 100644 --- a/scenes/storyteller/event_catalog.gd +++ b/scenes/storyteller/event_catalog.gd @@ -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