extends Node3D ## The wolf pack: when wolves exist, what they're doing, and who attacks. ## ## Schedule (a grace period so you can build first): ## - Days 1-2: the den is empty (old bones only). ## - Day 3: wolves move in. They rest in the den by day and howl at night, ## but don't hunt yet. A toast warns you. ## - Night 4 onward: the pack hunts. It's 2 wolves to start, 3 from day 6, ## 4 from day 9. At dawn they go back to the den. Wolves you killed are ## replaced at the den during the day, when you aren't watching. ## ## Night is 19:30 to 05:30, the same window the bed allows sleeping in. The ## small hours belong to the previous day's night. ## ## While hunting the pack circles you and lets one wolf at a time dart in ## (every few seconds, slower if you hold a torch). Near a lit campfire it ## circles wide around the fire and never closes in. ## ## Every 7th night is a wolf moon (day_night.gd turns the moon red): an ## alpha comes down from the hills with the pack. It leaves at dawn whether ## or not you killed it, and comes back the next wolf moon (wolf.gd). const WolfScript = preload("res://scripts/creatures/wolf.gd") const DayNight = preload("res://scripts/world/day_night.gd") const DEN_DAY := 3 ## wolves arrive in the den const HUNT_DAY := 4 ## first night they hunt const ALERT_RADIUS := 14.0 ## by day: closer than this to the den and they watch you const DEFEND_RADIUS := 8.0 ## ...and closer than this they attack const FIRE_SAFE := 8.0 ## within this of a lit campfire, wolves won't close in const RING := 10.0 ## circling radius around you const FIRE_RING := 14.0 ## circling radius around your fire const ATTACK_GAP := 4.5 ## seconds between darts (x2.5 with a torch) var world: Node3D var den: Node3D var den_pos := Vector3.ZERO var den_yaw := 0.0 var mode := "none" ## none | den | hunt var members: Array = [] var enabled := true var _rng := RandomNumberGenerator.new() var _lunger: Node = null var _gap := 2.0 var _circle_a := 0.0 var _warned := {} ## day -> true once that night's toast was shown var _howl_left := 0.0 var _audio: AudioStreamPlayer3D static var _howl: AudioStreamWAV var alpha: Node = null ## tonight's alpha (wolf moon) var _alpha_day := -1 ## the night it came var _told := {} ## "door id:outcome" -> true: door toasts already shown ## Night `day` is a wolf moon (once the pack hunts at all). The same rule as ## day_night.gd's red moon. func wolf_moon(day: int) -> bool: return day >= hunt_day and DayNight.wolf_moon_day(day) func setup(w: Node3D, d: Node3D) -> void: world = w den = d den_pos = d.position den_yaw = d.facing_out_yaw() _rng.seed = w.terrain.noise_seed + 779 if DisplayServer.get_name() != "headless": _audio = AudioStreamPlayer3D.new() _audio.stream = howl_stream() _audio.unit_size = 30.0 _audio.max_distance = 400.0 _audio.volume_db = 2.0 add_child(_audio) ## The "day" a moment belongs to: the hours after midnight count as the ## previous day's night. func effective_day() -> int: var h: float = world.day_night.hours() return world.day_night.day - (1 if h < 5.5 else 0) func is_night() -> bool: var h: float = world.day_night.hours() return h >= 19.5 or h < 5.5 ## The pack grows 3 and 6 days after it arrives (2, 3, then 4 wolves), plus ## or minus one for a server's difficulty. func pack_size(day: int) -> int: if day < den_day: return 0 var n := 4 if day < den_day + 3: n = 2 elif day < den_day + 6: n = 3 return clampi(n + pack_bonus, 1, 5) ## The schedule (a server sets it from its rules; single-player keeps 3/4). var den_day := DEN_DAY var hunt_day := HUNT_DAY var pack_bonus := 0 func alive() -> Array: var out := [] for m in members: if is_instance_valid(m) and not m.is_dead(): out.append(m) return out func alive_count() -> int: return alive().size() func _physics_process(delta: float) -> void: if enabled: step(delta) ## Pack-level logic (the wolves run their own step()). func step(dt: float) -> void: var day := effective_day() var night := is_night() var want := "none" if day >= den_day: want = "hunt" if night and day >= hunt_day else "den" if want != mode: _enter(want, day) if mode == "none": return if night: _warn(day) # Top up the den by day while you're not looking. if not night and alive_count() < pack_size(day): fill_den(false) _gap = maxf(_gap - dt, 0.0) for t in _gaps.keys(): _gaps[t] = maxf(float(_gaps[t]) - dt, 0.0) if not is_instance_valid(t): _gaps.erase(t) _circle_a += dt * 0.25 if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"): _lunger = null # At dawn wolves head home; once one is well away from you (out of sight), # it slips straight back rather than picking its way across the island. if mode == "den": for m in alive(): if m.global_position.distance_to(den_pos) > 6.0 and _far_from_everyone(m.global_position, 45.0) \ and _far_from_everyone(den_pos, 35.0): m.global_position = den.slot_pos(m.slot) m.wounded = false m.state = "rest" if night and day >= den_day: _howl_left -= dt if _howl_left <= 0.0: _howl_left = _rng.randf_range(50.0, 110.0) howl() func _enter(m: String, day: int) -> void: mode = m if m == "hunt" and wolf_moon(day) and _alpha_day != day: spawn_alpha(day) elif m != "hunt": _alpha_leaves() if m == "none": for w in members: if is_instance_valid(w): w.queue_free() members.clear() return if m == "den": for w in alive(): w.wounded = false if members.is_empty(): fill_den(true) ## Once per night: a toast on the first howling night and the first hunt. func _warn(day: int) -> void: if _warned.has(day): return _warned[day] = true if day == den_day: Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.") elif day == hunt_day: Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.") elif wolf_moon(day): Actions.tell_all("The moon is red: a wolf moon. Something big is hunting with the pack tonight.") _howl_left = _rng.randf_range(2.0, 6.0) ## Brings the pack up to size, wolves lying in the den. `force` ignores ## whether you're watching. func fill_den(force: bool) -> void: var need: int = pack_size(effective_day()) - alive_count() if need <= 0: return if not force and not _far_from_everyone(den_pos, 35.0): return members = alive() var used := {} for m in members: used[m.slot] = true var i := 0 while need > 0 and i < 4: if not used.has(i): var w: AnimatableBody3D = WolfScript.new() w.setup("wolf", world, den.slot_pos(i), _rng.randi()) w.pack = self w.slot = i w.state = "rest" w.yaw = den_yaw + (i - 1.5) * 0.6 w.rotation.y = w.yaw add_child(w) members.append(w) need -= 1 i += 1 ## The alpha comes out of the den's mouth with the pack. func spawn_alpha(day: int) -> Node: _alpha_leaves() _alpha_day = day var w: AnimatableBody3D = WolfScript.new() w.setup("alpha", world, den.mouth_pos(), _rng.randi()) w.pack = self w.slot = 4 w.state = "stalk" w.yaw = den_yaw w.rotation.y = w.yaw add_child(w) members.append(w) alpha = w return w ## At dawn it goes back into the hills (a carcass stays for butchering). func _alpha_leaves() -> void: if alpha != null and is_instance_valid(alpha) and not alpha.is_dead(): members.erase(alpha) alpha.queue_free() alpha = null func alpha_alive() -> bool: return alpha != null and is_instance_valid(alpha) and not alpha.is_dead() ## The door the alpha `w` should go for to get at `target`: the closed door ## nearest `target` (within `r`), a little biased toward ones nearer `w`. ## `open_ones`: the nearest open or broken door instead (a way in). func door_for(w: Node3D, target: Node3D, r: float, open_ones: bool = false) -> Node3D: var best: Node3D = null var best_s := INF var tp: Vector3 = target.global_position for p in world.build.build_pieces(): if not p.has_method("bash") or p.blocks() == open_ones: continue var d: float = p.global_position.distance_to(tp) if d > r: continue var score: float = d + 0.3 * p.global_position.distance_to(w.global_position) if score < best_s: best_s = score best = p return best ## The alpha hits `door`. Tells the player it's after (once per door and ## outcome); on a server, every game hears the door broke. func bash_door(w: Node, door: Node3D, damage: float) -> void: var res: String = door.bash(damage) var key := "%d:%s" % [door.get_instance_id(), res] if not _told.has(key): _told[key] = true match res: "hit": Actions.tell(w.player, "Something is battering at your door!") "held": Actions.tell(w.player, "Something is battering at your door, but the iron bands hold.") "broke": Actions.tell(w.player, "The alpha has smashed your door in!") if res == "broke" and world.server_mode: WorldNet.piece_state(door) func home_slot(w: Node) -> Vector3: return den.slot_pos(w.slot) func _far_from_everyone(pos: Vector3, r: float) -> bool: for p in world.players(): if p.global_position.distance_to(pos) < r: return false return true # --- who the pack goes after ---------------------------------------------------- # In single-player that's you. On a server the pack splits between the # players it's hunting: wolf 1 takes the first, wolf 2 the second, and so on, # preferring players away from a fire (you can't be got at by a fire, so # they'd rather go for someone who isn't). Each target gets their own circle # and their own one-wolf-at-a-time darts. var _targets: Array = [] var _target_t := 0.0 ## Who wolf `w` is after right now (null: nobody about). func target_for(w: Node) -> Node3D: if not world.server_mode: return world.player if mode != "hunt": return world.nearest_player((w as Node3D).global_position) _refresh_targets() if _targets.is_empty(): return null var i := alive().find(w) return _targets[maxi(i, 0) % _targets.size()] ## Players worth hunting, nearest the den first: those away from a fire if ## there are any, otherwise everyone (they'll circle the fire). func _refresh_targets() -> void: var now := Time.get_ticks_msec() / 1000.0 if now - _target_t < 1.0 and _targets.all(func(p): return is_instance_valid(p) and not p.survival.dead): return _target_t = now var everyone: Array = world.players() var open := everyone.filter(func(p): return fire_near(p) == null) _targets = open if not open.is_empty() else everyone _targets.sort_custom(func(a, b): return a.global_position.distance_to(den_pos) < b.global_position.distance_to(den_pos)) func _p(p: Node3D) -> Node3D: return p if p != null else world.player ## The lit campfire `p` is sheltering by, if any. func fire_near(p: Node3D) -> Node3D: var pp: Vector3 = _p(p).global_position for f in get_tree().get_nodes_in_group("station:campfire"): if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE: return f return null ## (Single-player name, used by tests.) func player_fire() -> Node3D: return fire_near(world.player) func _torch(p: Node3D = null) -> bool: var who := _p(p) return who.get("hands") != null and who.hands.held_heat() > 0.0 ## Wolves only close in when you're away from a fire. func may_close_in(p: Node3D = null) -> bool: return fire_near(p) == null and not _p(p).survival.dead ## The wolves going after the same player as `w`. func _hunting_with(w: Node) -> Array: var t := target_for(w) return alive().filter(func(m): return target_for(m) == t) ## Where wolf `w` should be while circling. func circle_point(w: Node) -> Vector3: var t := _p(target_for(w)) var fire := fire_near(t) var center: Vector3 = fire.global_position if fire != null else t.global_position var r := FIRE_RING if fire != null else RING * (1.3 if _torch(t) else 1.0) var group := _hunting_with(w) var n := maxi(group.size(), 1) var a := _circle_a + TAU * float(maxi(group.find(w), 0)) / n return center + Vector3(cos(a), 0, sin(a)) * r ## Darting in: one wolf at a time per player being hunted. var _lungers: Dictionary = {} ## target -> wolf var _gaps: Dictionary = {} ## target -> seconds until the next dart ## A wolf asks for its turn to dart in at `p`. func request_lunge(w: Node, p: Node3D = null) -> bool: var t := _p(p) if not world.server_mode: if _lunger != null or _gap > 0.0 or not may_close_in(t) or alive_count() < 2: return false else: var cur: Node = _lungers.get(t) if cur != null and is_instance_valid(cur) and not cur.is_dead() and cur.state == "lunge": return false if float(_gaps.get(t, 0.0)) > 0.0 or not may_close_in(t) or alive_count() < 2: return false if w.global_position.distance_to(t.global_position) > RING * 1.8: return false _lunger = w _lungers[t] = w return true func lunge_done(w: Node) -> void: if _lunger == w: _lunger = null if not world.server_mode: _gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3) return var t: Node3D = target_for(w) if w != null and is_instance_valid(w) else null for k in _lungers.keys(): if _lungers[k] == w: _lungers.erase(k) if t != null: _gaps[t] = ATTACK_GAP * (2.5 if _torch(t) else 1.0) * _rng.randf_range(0.8, 1.3) ## A wolf was hit: the whole pack turns on you. func alert(_from: Vector3) -> void: for m in alive(): m._aggro = maxf(m._aggro, 20.0) func howl() -> void: var live := alive() if live.is_empty(): return if world.server_mode: CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it return Hints.event("howl") if _audio == null: return var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper) _audio.global_position = from.global_position + Vector3(0, 1, 0) _audio.pitch_scale = _rng.randf_range(0.68, 0.76) if alpha_alive() else _rng.randf_range(0.9, 1.1) _audio.play() ## A synthesised wolf howl: a rising then falling tone with vibrato and a ## breathy edge, ~2.8 s. Built once; no audio files. static func howl_stream() -> AudioStreamWAV: if _howl != null: return _howl var rate := 22050 var secs := 2.8 var n := int(rate * secs) var data := PackedByteArray() data.resize(n * 2) var phase := 0.0 var rng := RandomNumberGenerator.new() rng.seed = 5 var lp := 0.0 for i in range(n): var t := float(i) / rate var u := t / secs # Pitch: swoop up, hold, sag at the end. var f := 330.0 + 260.0 * smoothstep(0.0, 0.25, u) - 140.0 * smoothstep(0.65, 1.0, u) f += sin(t * TAU * 5.5) * 6.0 * smoothstep(0.2, 0.4, u) phase += TAU * f / rate var env := smoothstep(0.0, 0.08, u) * (1.0 - smoothstep(0.75, 1.0, u)) lp += 0.15 * ((rng.randf() * 2.0 - 1.0) - lp) var s := (sin(phase) * 0.7 + sin(phase * 2.0) * 0.18 + sin(phase * 3.0) * 0.06 + lp * 0.12) * env * 0.6 data.encode_s16(i * 2, int(clampf(s, -1.0, 1.0) * 32000.0)) var wav := AudioStreamWAV.new() wav.format = AudioStreamWAV.FORMAT_16_BITS wav.mix_rate = rate wav.stereo = false wav.data = data _howl = wav return wav