Wolves with a den, fishing, and a mixed diet

Wolves
- Den: a boulder cave at the foot of the rim, sited from the seed (away
  from the sea and spawn, mouth toward the valley, clearing in front)
- Grace period: no wolves days 1-2; they move in on day 3 and howl; hunting
  from night 4; pack of 2, 3 from day 6, 4 from day 9; replaced by day
- Wary pack hunters: circle and dart in one at a time; a lit campfire is a
  safe zone, a torch makes them hesitate, walls block bites (line of
  sight); hit one and the pack turns; hurt wolves flee; a lone wolf is
  timid; by day they defend the den; home at dawn
- Glowing eyes, synthesised howls, can't sleep with wolves prowling
- Wolf pelt and Wolf Cloak (+25 warmth)
- Animals take a committed detour around obstacles

Fishing
- Fishing rod (workbench); cast a float onto the lake, 1.5 s to land a bite
- Perch and rarer trout; faster bites at dawn/dusk and in rain; grill them
- Visible fish near the shore that jump with splash rings

Mixed diet
- Foods belong to meat, fish or greens; each level runs down over ~1.5 days
- One group low: no healing; two low: Malnourished (health drains);
  all three good: Balanced diet (heal x1.5); HUD diet bars; saved

Tests: test_wolves and test_fishing_diet; run_all.sh covers 9 suites
This commit is contained in:
Paul Bailey
2026-11-04 11:32:16 +00:00
parent 67bf85523d
commit f174221268
23 changed files with 2069 additions and 21 deletions

View File

@@ -34,6 +34,11 @@ const KINDS := {
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"damage": 14.0, "attack_range": 1.7, "attack_cd": 1.6, "aggro": 18.0,
"drops": {"raw_meat": [3, 4], "hide": [1, 2]}},
# Wolves are driven by wolf.gd + wolf_pack.gd; "notice" is unused.
"wolf": {"name": "Wolf", "hp": 45.0, "walk": 1.6, "run": 8.6, "turn": 6.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
"damage": 9.0, "attack_range": 1.8, "attack_cd": 1.2,
"drops": {"raw_meat": [1, 2], "wolf_pelt": [1, 1]}},
}
const CARCASS_SECONDS := 300.0
@@ -60,6 +65,13 @@ var _phase := 0.0
var _visual: Node3D
var _legs: Array[Node3D] = []
var _flash := 0.0
var _detour_t := 0.0 ## > 0: following a detour round an obstacle
var _detour_side := 1.0
## Sets the heading, unless the animal is in the middle of a detour.
func steer(y: float) -> void:
if _detour_t <= 0.0:
target_yaw = y
var _mat: StandardMaterial3D
var _hit_mat: StandardMaterial3D
@@ -88,6 +100,18 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
body.mesh = parts.body
body.material_override = _mat
_visual.add_child(body)
if parts.has("eyes"):
# Eyes that catch the light: you see a wolf's eyes in the dark first.
var eyes := MeshInstance3D.new()
eyes.mesh = parts.eyes
var em := StandardMaterial3D.new()
em.albedo_color = Color(1.0, 0.85, 0.4)
em.emission_enabled = true
em.emission = Color(1.0, 0.75, 0.3)
em.emission_energy_multiplier = 2.5
eyes.material_override = em
eyes.set_meta("keep_mat", true)
_visual.add_child(eyes)
for h: Vector3 in parts.hips:
var pivot := Node3D.new()
pivot.position = h
@@ -105,7 +129,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
cs.position = Vector3(0, parts.height * 0.5, 0)
if k != "rabbit":
# Long bodies: lay the capsule along the spine.
cap.height = 1.5 if k == "deer" else 1.4
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25}.get(k, 1.4)
cs.rotation.x = PI * 0.5
cs.position = Vector3(0, parts.height * 0.55, 0)
add_child(cs)
@@ -219,12 +243,19 @@ func _move(dt: float) -> void:
yaw = rotate_toward(yaw, target_yaw, def.turn * dt)
if speed < 0.01:
return
_detour_t = maxf(_detour_t - dt, 0.0)
var dir := Vector3(-sin(yaw), 0, -cos(yaw))
if _blocked(dir):
# Turn away; fleeing animals turn harder.
var turn := rng.randf_range(0.8, 2.2) * (1.0 if rng.randf() < 0.5 else -1.0)
# Turn away and keep to the new heading for a moment (a detour), so
# an animal heading somewhere slides round an obstacle instead of
# turning straight back into it. Keep turning the same way while
# still blocked.
if _detour_t <= 0.0:
_detour_side = 1.0 if rng.randf() < 0.5 else -1.0
var turn := rng.randf_range(0.8, 1.6) * _detour_side
target_yaw = yaw + turn
yaw += turn * 0.35
_detour_t = rng.randf_range(1.0, 1.8)
speed *= 0.5
if state == "charge":
return
@@ -253,6 +284,13 @@ func _blocked(dir: Vector3) -> bool:
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _animate(dt: float) -> void:
if state == "rest":
# Lying down: body lowered, legs folded under.
_visual.position.y = lerpf(_visual.position.y, -0.3, minf(dt * 3.0, 1.0))
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, minf(dt * 4.0, 1.0))
for l in _legs:
l.rotation.x = lerpf(l.rotation.x, 1.35 * (1.0 if l.position.z < 0.0 else -1.0), minf(dt * 3.0, 1.0))
return
var k := speed / maxf(def.run, 0.01)
_phase += dt * (3.0 + speed * 2.2)
var amp := clampf(speed / 2.0, 0.0, 1.0) * 0.6
@@ -288,6 +326,10 @@ func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
if hp <= 0.0:
_die(from)
return
_react(from)
## How a living animal responds to being hit (wolves override this).
func _react(from: Vector3) -> void:
if kind == "boar" and hp > def.hp * 0.3:
state = "charge"
_aggro = def.aggro
@@ -328,10 +370,10 @@ func butcher_drops() -> Dictionary:
func _set_material(m: Material) -> void:
for c in _visual.get_children():
if c is MeshInstance3D:
if c is MeshInstance3D and not c.has_meta("keep_mat"):
c.material_override = m
for g in c.get_children():
if g is MeshInstance3D:
if g is MeshInstance3D and not g.has_meta("keep_mat"):
g.material_override = m
func _burst() -> void:

View File

@@ -17,6 +17,7 @@ static func build(kind: String) -> Dictionary:
match kind:
"rabbit": d = _rabbit()
"deer": d = _deer()
"wolf": d = _wolf()
_: d = _boar()
_cache[kind] = d
return d
@@ -96,3 +97,29 @@ static func _boar() -> Dictionary:
return {"body": P._commit(st), "leg": _leg(0.36, 0.07, 0.05, bristle, Color(0.15, 0.12, 0.1), 222),
"hips": [Vector3(-0.17, 0.38, -0.36), Vector3(0.17, 0.38, -0.36), Vector3(-0.17, 0.4, 0.38), Vector3(0.17, 0.4, 0.38)],
"radius": 0.42, "height": 0.95}
## Grey wolf: lean body, long snout, pointed ears, bushy tail. "eyes" is a
## separate mesh so it can glow (creature.gd gives it an emissive material).
static func _wolf() -> Dictionary:
var coat := Color(0.42, 0.42, 0.44)
var pale := Color(0.72, 0.71, 0.68)
var dark := Color(0.16, 0.15, 0.15)
var st := P._begin()
var r := _rng(231)
var n := _noise(231, 1.5)
P._blob(st, Vector3(0, 0.7, 0.05), Vector3(0.22, 0.24, 0.55), n, 0.15, coat, r, 1)
P._blob(st, Vector3(0, 0.8, -0.32), Vector3(0.24, 0.26, 0.3), n, 0.25, P._mul(coat, 1.08), r, 1) # ruff
P._blob(st, Vector3(0, 0.6, -0.1), Vector3(0.16, 0.12, 0.35), n, 0.1, pale, r, 1) # belly
P._blob(st, Vector3(0, 0.92, -0.62), Vector3(0.15, 0.14, 0.17), n, 0.12, coat, r, 1) # head
P._cylinder(st, Vector3(0, 0.88, -0.72), Vector3(0, 0.83, -0.95), 0.09, 0.05, 6, pale, r, -1.0, dark) # snout
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.08, 1.02, -0.6), Vector3(s * 0.1, 1.2, -0.58), 0.05, 0.008, 4, P._mul(coat, 0.85), r)
# Bushy tail, hanging down and back.
P._cylinder(st, Vector3(0, 0.78, 0.55), Vector3(0, 0.5, 0.85), 0.07, 0.05, 5, coat, r)
P._blob(st, Vector3(0, 0.45, 0.9), Vector3(0.06, 0.08, 0.06), n, 0.2, pale, r, 0)
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.065, 0.97, -0.75), Vector3.ONE * 0.022, n, 0.0, Color(1.0, 0.8, 0.3), r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, 232),
"hips": [Vector3(-0.11, 0.52, -0.38), Vector3(0.11, 0.52, -0.38), Vector3(-0.11, 0.54, 0.38), Vector3(0.11, 0.54, 0.38)],
"radius": 0.32, "height": 1.0}

174
scripts/creatures/den.gd Normal file
View File

@@ -0,0 +1,174 @@
extends Node3D
## The wolf den: a cave mouth of piled boulders at the foot of the mountain
## rim, opening toward the valley. Built from the same procedural rocks as the
## scenery, so it's part of the island and regenerates from the seed.
##
## The site is chosen deterministically: a flat, clear spot close to the rim,
## away from the sea and the spawn. The rocks' colliders are also on the
## building layer, so standing inside the cave counts as being under a roof
## (it's dry in there, if you dare).
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const BONE := Color(0.88, 0.85, 0.76)
## [local position, radii] of each boulder. The interior is about 3.6 m
## wide, 4 m deep and 2 m high; +Z is the mouth.
const ROCKS := [
[Vector3(0, 1.2, -3.4), Vector3(3.2, 2.4, 1.6)], # back wall
[Vector3(-2.6, 1.1, -1.4), Vector3(1.4, 2.2, 2.4)], # left wall
[Vector3(-2.4, 0.9, 0.9), Vector3(1.3, 1.8, 1.6)],
[Vector3(2.6, 1.2, -1.5), Vector3(1.5, 2.3, 2.4)], # right wall
[Vector3(2.3, 0.8, 0.9), Vector3(1.2, 1.6, 1.5)],
[Vector3(0, 3.0, -1.2), Vector3(3.4, 0.9, 3.0)], # roof slab
[Vector3(0, 2.0, -5.6), Vector3(4.5, 3.4, 3.0)], # mound behind
[Vector3(-3.6, 1.6, -4.4), Vector3(2.5, 2.6, 2.2)],
[Vector3(3.7, 1.8, -4.3), Vector3(2.6, 2.8, 2.3)],
[Vector3(-2.5, 0.5, 2.6), Vector3(1.0, 0.8, 0.9)], # boulders at the mouth
[Vector3(2.7, 0.45, 2.4), Vector3(0.9, 0.7, 0.8)],
]
## Where the wolves lie (local), facing out of the mouth.
const SLOTS := [Vector3(-0.9, 0, -0.9), Vector3(1.0, 0, -1.3), Vector3(0.0, 0, -2.0), Vector3(-0.2, 0, 0.4)]
const HALF_SIZE := 6.5 ## clear area needed around the site
var terrain: Node3D
var yaw := 0.0 ## node rotation; local +Z points into the valley
var found := false
## Finds the site and builds the den. Returns false (and builds nothing) if
## no spot qualifies on this seed.
func place(world: Node3D, rock_mat: Material) -> bool:
terrain = world.terrain
var site := _find_site(world)
if site == Vector3.INF:
push_warning("[den] no site found for the wolf den on this seed")
return false
found = true
position = site
yaw = atan2(-site.x, -site.z) # mouth toward the centre of the valley
rotation.y = yaw
_clear_area(world.props)
_build(rock_mat)
return true
func _find_site(world: Node3D) -> Vector3:
var rng := RandomNumberGenerator.new()
rng.seed = terrain.noise_seed + 777
var start := rng.randf() * 360.0
var space := world.get_world_3d().direct_space_state
var box := BoxShape3D.new()
box.size = Vector3(HALF_SIZE * 2.0, 6.0, HALF_SIZE * 2.0)
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()]
for step in range(52):
var ang := deg_to_rad(start + step * 7.0)
var dir := Vector2(cos(ang), sin(ang))
# Walk inward from the rim until the den sits inside the walkable
# area, a little way short of the wall.
var r: float = terrain.play_radius + 10.0
while r > terrain.play_radius - 40.0:
var x := dir.x * r
var z := dir.y * r
if terrain.inside_boundary(x, z, 11.0):
break
r -= 1.0
var c := Vector3(dir.x * r, 0, dir.y * r)
if terrain.sea_weight(c.x, c.z) > 0.02 or Vector2(c.x, c.z).length() < 50.0:
continue
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(c.x + d.x * 4.0, c.z + d.y * 4.0))
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
continue
c.y = hs[4]
# Trees and boulders in the way are cleared (_clear_area); just avoid
# the thickest woods. Counted by position, because their colliders
# were only just created and the physics space hasn't seen them yet.
var crowd := 0
for uid in world.props.resource_nodes:
var n: Node3D = world.props.resource_nodes[uid]
if Vector2(n.global_position.x - c.x, n.global_position.z - c.z).length() < HALF_SIZE:
crowd += 1
if crowd <= 5:
return c
return Vector3.INF
## Removes the trees, boulders, bushes and pickups the den is built over.
## Deterministic (the site comes from the seed), and the other scenery keeps
## its save ids because those come from creation order.
func _clear_area(props: Node3D) -> void:
var r := HALF_SIZE
# Also a clearing in front of the mouth, so you can see the cave from the valley.
var b := Basis(Vector3.UP, yaw)
var front: Vector3 = position + b * Vector3(0, 0, 9.0)
for dict in [props.resource_nodes, props.pickups, props.forage]:
for uid in dict.keys():
var n: Node3D = dict[uid]
if not is_instance_valid(n):
continue
var p2 := Vector2(n.global_position.x, n.global_position.z)
if p2.distance_to(Vector2(position.x, position.z)) < r or p2.distance_to(Vector2(front.x, front.z)) < 6.0:
if n.has_method("_set_instance"):
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
dict.erase(uid)
n.queue_free()
func _build(rock_mat: Material) -> void:
var body := StaticBody3D.new()
body.name = "Rocks"
body.collision_layer = 1 | 4 # solid, and a roof for shelter checks
add_child(body)
var b := Basis(Vector3.UP, yaw)
for i in range(ROCKS.size()):
var lp: Vector3 = ROCKS[i][0]
var radii: Vector3 = ROCKS[i][1]
# Sit each boulder on the ground under it (the site isn't perfectly flat).
var w: Vector3 = position + b * Vector3(lp.x, 0, lp.z)
var y: float = terrain.sample_height(w.x, w.z) - position.y + lp.y - 0.25
var mesh := ProcMesh.rock(terrain.noise_seed + 3100 + i, false)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = rock_mat
var turn := Basis(Vector3.UP, float(i) * 1.7)
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
add_child(mi)
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(turn * (p * radii))
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = Vector3(lp.x, y, lp.z)
body.add_child(cs)
# Old bones on the cave floor.
var st := ProcMesh._begin()
var rng := RandomNumberGenerator.new()
rng.seed = terrain.noise_seed + 778
var n := FastNoiseLite.new()
n.seed = 778
for k in range(7):
var c := Vector3(rng.randf_range(-1.4, 1.4), 0.0, rng.randf_range(-2.4, 1.2))
var a := rng.randf() * TAU
var dir := Vector3(cos(a), 0, sin(a)) * rng.randf_range(0.15, 0.3)
ProcMesh._cylinder(st, c - dir + Vector3(0, 0.04, 0), c + dir + Vector3(0, 0.04, 0), 0.025, 0.02, 5, BONE, rng, -1.0, BONE)
ProcMesh._blob(st, c + dir + Vector3(0, 0.05, 0), Vector3.ONE * 0.045, n, 0.2, BONE, rng, 0)
var bones := MeshInstance3D.new()
bones.mesh = ProcMesh._commit(st)
bones.material_override = ItemDB.item_material()
add_child(bones) # (the den's origin is the cave floor)
## World position of resting slot `i`, on the ground.
func slot_pos(i: int) -> Vector3:
var lp: Vector3 = SLOTS[i % SLOTS.size()]
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
w.y = terrain.sample_height(w.x, w.z)
return w
## Yaw a wolf uses to face out of the mouth (forward is -Z).
func facing_out_yaw() -> float:
var out := Basis(Vector3.UP, yaw) * Vector3(0, 0, 1)
return atan2(-out.x, -out.z)
func mouth_pos() -> Vector3:
var w: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, 3.5)
w.y = terrain.sample_height(w.x, w.z)
return w

163
scripts/creatures/wolf.gd Normal file
View File

@@ -0,0 +1,163 @@
extends "res://scripts/creatures/creature.gd"
## A wolf: a member of the pack that lives in the den (wolf_pack.gd decides
## what the pack is doing; this decides what one wolf does about it).
##
## Wary pack hunters rather than berserkers:
## - In the den by day they rest, but they defend it. Come within 14 m and
## they get up and watch you; within 8 m (or hit one) and they attack.
## - At night the pack stalks you. They circle at a distance and take turns
## darting in to bite, one wolf at a time.
## - Fire keeps them back: near a lit campfire they circle wide and never
## close in. A held torch makes them hesitate.
## - They can't bite through walls (a line-of-sight check), so a closed hut
## is safe.
## - A wolf on its own is timid: it keeps its distance and backs off.
## - A badly hurt wolf runs, and comes back another night.
var pack: Node
var slot := 0
var wounded := false ## ran off hurt: stays away until dawn
func display_name() -> String:
return "Wolf"
func interact_prompt(p: Node) -> String:
if is_dead():
return "Wolf carcass [E] Butcher"
var s := "Wolf"
match state:
"rest": s = "Wolf (resting)"
"alert", "stalk": s = "Wolf (watching you)"
"lunge", "defend": s = "Wolf (attacking!)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon)"
return s
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
if state == "flee":
var away := global_position - _threat
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0 and d > 30.0:
state = "home"
return
if wounded and pack.mode == "hunt":
_go_to(pack.home_slot(self))
state = "home"
return
match pack.mode:
"den":
_think_den(pp, d)
"hunt":
if _aggro > 0.0 and d < 25.0:
_attack(pp, d, "defend")
elif pack.alive_count() <= 1:
_think_alone(pp, d)
else:
_think_hunt(pp, d)
_:
_think_den(pp, d)
## Daytime (and the howling night before hunts begin): rest at the den,
## defend it if you come too close.
func _think_den(pp: Vector3, d: float) -> void:
var den_d: float = pack.den_pos.distance_to(pp)
if (_aggro > 0.0 or den_d < pack.DEFEND_RADIUS) and d < 25.0:
_attack(pp, d, "defend")
return
if den_d < pack.ALERT_RADIUS:
state = "alert"
var to := pp - global_position
target_yaw = atan2(-to.x, -to.z)
return
var home: Vector3 = pack.home_slot(self)
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > 1.2:
state = "home"
_go_to(home)
else:
state = "rest"
target_yaw = pack.den_yaw + (slot - 1.5) * 0.6
## Night, with the pack: circle, and dart in when it's your turn.
func _think_hunt(pp: Vector3, d: float) -> void:
match state:
"lunge":
# Break off first if you've reached a fire or it's taking too long.
if _state_t <= 0.0 or not pack.may_close_in():
state = "stalk"
pack.lunge_done(self)
else:
_attack(pp, d, "lunge")
"retreat":
var away := global_position - pp
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0:
state = "stalk"
_:
state = "stalk"
_go_to(pack.circle_point(self))
if pack.request_lunge(self):
state = "lunge"
_state_t = 4.0
## The last wolf of a pack keeps its distance and won't start a fight.
func _think_alone(pp: Vector3, d: float) -> void:
if d < 10.0:
_threat = pp
state = "flee"
_state_t = 3.0
return
state = "stalk"
_go_to(pp + (global_position - pp).normalized() * 18.0)
## Run at the player and bite when in range with nothing in between.
func _attack(pp: Vector3, d: float, as_state: String) -> void:
state = as_state
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
_attack_cd = def.attack_cd
player.survival.damage(def.damage, global_position)
if as_state == "lunge":
state = "retreat"
_state_t = 1.6
pack.lunge_done(self)
## No wall, door or rock between the wolf's jaws and the player.
func _can_reach_player() -> bool:
var from := global_position + Vector3(0, 0.8, 0)
var to: Vector3 = player.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), player.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
var _goal_d := 0.0
func _desired_speed() -> float:
match state:
"rest", "alert": return 0.0
"home": return 2.2 if _goal_d < 8.0 else 6.0
"stalk": return 0.0 if _goal_d < 1.0 else (3.4 if _goal_d < 7.0 else 7.5)
"lunge", "defend": return def.run
"retreat": return 6.0
"flee": return def.run
return 0.0
func _react(from: Vector3) -> void:
pack.alert(from)
if hp < def.hp * 0.4:
wounded = true
_threat = from
state = "flee"
_state_t = 6.0
else:
_aggro = 20.0

View File

@@ -0,0 +1,267 @@
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.
const WolfScript = preload("res://scripts/creatures/wolf.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
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
static func pack_size(day: int) -> int:
if day < DEN_DAY:
return 0
if day < 6:
return 2
if day < 9:
return 3
return 4
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)
_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":
var pp: Vector3 = world.player.global_position
for m in alive():
if m.global_position.distance_to(den_pos) > 6.0 and m.global_position.distance_to(pp) > 45.0 \
and pp.distance_to(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 == "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.notify.emit("Wolves are howling up in the hills. They'll come hunting soon.")
elif day == HUNT_DAY:
Actions.notify.emit("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
_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 world.player.global_position.distance_to(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
func home_slot(w: Node) -> Vector3:
return den.slot_pos(w.slot)
## The lit campfire you're sheltering by, if any.
func player_fire() -> Node3D:
var pp: Vector3 = world.player.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
func _torch() -> bool:
return world.player.hands != null and world.player.hands.held_heat() > 0.0
## Wolves only close in when you're away from a fire.
func may_close_in() -> bool:
return player_fire() == null and not world.player.survival.dead
## Where wolf `w` should be while circling.
func circle_point(w: Node) -> Vector3:
var fire := player_fire()
var center: Vector3 = fire.global_position if fire != null else world.player.global_position
var r := FIRE_RING if fire != null else RING * (1.3 if _torch() else 1.0)
var live := alive()
var n := maxi(live.size(), 1)
var a := _circle_a + TAU * float(live.find(w)) / n
return center + Vector3(cos(a), 0, sin(a)) * r
## A wolf asks for its turn to dart in.
func request_lunge(w: Node) -> bool:
if _lunger != null or _gap > 0.0 or not may_close_in() or alive_count() < 2:
return false
if w.global_position.distance_to(world.player.global_position) > RING * 1.8:
return false
_lunger = w
return true
func lunge_done(w: Node) -> void:
if _lunger == w:
_lunger = null
_gap = ATTACK_GAP * (2.5 if _torch() 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 _audio == null or live.is_empty():
return
_audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0)
_audio.pitch_scale = _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