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 (0–1) 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)