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

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