class_name Wolf extends Node2D ## Wolf entity — hostile animal with a 4-state AI state machine. ## ## 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 # ── 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 ## 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) queue_redraw() 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) queue_redraw() 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) queue_redraw() func is_dead() -> bool: return state == State.DEAD # ── 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) 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) 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) 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 ───────────────────────────────────────────────────────────────── 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)