439 lines
15 KiB
GDScript
439 lines
15 KiB
GDScript
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
|