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

@@ -137,7 +137,10 @@
"mesh": "berries",
"food": {
"hunger": 9,
"health": 2
"health": 2,
"diet": {
"plants": 14
}
}
},
{
@@ -149,7 +152,10 @@
"mesh": "mushroom",
"food": {
"hunger": 6,
"health": 0
"health": 0,
"diet": {
"plants": 10
}
}
},
{
@@ -161,7 +167,10 @@
"mesh": "roasted_mushroom",
"food": {
"hunger": 24,
"health": 8
"health": 8,
"diet": {
"plants": 30
}
}
},
{
@@ -277,7 +286,10 @@
"mesh": "raw_meat",
"food": {
"hunger": 5,
"health": -4
"health": -4,
"diet": {
"meat": 8
}
}
},
{
@@ -290,6 +302,9 @@
"food": {
"hunger": 34,
"health": 12,
"diet": {
"meat": 40
},
"buff": {
"id": "well_fed",
"name": "Well fed",
@@ -374,6 +389,98 @@
"warmth": 15,
"wet_chill": 0.6
}
},
{
"id": "wolf_pelt",
"name": "Wolf Pelt",
"category": "resource",
"stack": 20,
"description": "Thick grey fur. The warmest thing on the island.",
"mesh": "wolf_pelt"
},
{
"id": "wolf_cloak",
"name": "Wolf Cloak",
"category": "tool",
"stack": 1,
"description": "Right-click to wear it (+25 warmth, wet barely chills you). Take it off from the inventory screen.",
"mesh": "wolf_cloak",
"wear": {
"warmth": 25,
"wet_chill": 0.35
}
},
{
"id": "fishing_rod",
"name": "Fishing Rod",
"category": "tool",
"stack": 1,
"description": "LMB casts into the lake. When the float dips, click quickly to land the fish.",
"mesh": "fishing_rod",
"fishing": {
"cast_speed": 12.0,
"stamina": 4
}
},
{
"id": "perch",
"name": "Perch",
"category": "food",
"stack": 20,
"description": "A small lake fish. Grill it at a campfire. (Fish)",
"mesh": "perch",
"food": {
"hunger": 4,
"health": -2,
"diet": {
"fish": 8
}
}
},
{
"id": "trout",
"name": "Trout",
"category": "food",
"stack": 20,
"description": "A fine, fat lake trout. Bites best at dawn and dusk. (Fish)",
"mesh": "trout",
"food": {
"hunger": 6,
"health": -2,
"diet": {
"fish": 12
}
}
},
{
"id": "grilled_perch",
"name": "Grilled Perch",
"category": "food",
"stack": 20,
"description": "Flaky and good. (Fish)",
"mesh": "grilled_perch",
"food": {
"hunger": 18,
"health": 6,
"diet": {
"fish": 30
}
}
},
{
"id": "grilled_trout",
"name": "Grilled Trout",
"category": "food",
"stack": 20,
"description": "Rich and filling. (Fish)",
"mesh": "grilled_trout",
"food": {
"hunger": 28,
"health": 10,
"diet": {
"fish": 45
}
}
}
]
}

View File

@@ -185,6 +185,46 @@
"hide": 4,
"fiber": 4
}
},
{
"id": "wolf_cloak",
"output": "wolf_cloak",
"count": 1,
"station": "workbench",
"inputs": {
"wolf_pelt": 3,
"hide": 2,
"fiber": 4
}
},
{
"id": "fishing_rod",
"output": "fishing_rod",
"count": 1,
"station": "workbench",
"inputs": {
"stick": 2,
"fiber": 3,
"flint": 1
}
},
{
"id": "grilled_perch",
"output": "grilled_perch",
"count": 1,
"station": "campfire",
"inputs": {
"perch": 1
}
},
{
"id": "grilled_trout",
"output": "grilled_trout",
"count": 1,
"station": "campfire",
"inputs": {
"trout": 1
}
}
]
}

View File

@@ -33,7 +33,9 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
- [x] Snap-together building: floor/wall/door/roof/gable, resource cost, support + collapse, dismantling *(M1c)*
- [x] Bed spawn point and sleeping, resource regrowth *(M1c)*
- [x] Creatures (rabbit, deer, boar), hunting, club/spear/bow, butchering, cooked-food buffs, hide cloak *(M1d)*
- [ ] Wolves at night, hide armour, more recipes *(later M1 polish)*
- [x] Wolves at night with a den and a 3-day grace period, wolf cloak *(M1e)*
- [x] Fishing (rod, perch and trout, grilling) and a mixed diet *(M1e)*
- [ ] Hide armour, more recipes, sea fishing *(later M1 polish)*
- **Done when:** gather → craft → build a tiny shelter, single-player.
## M2 — Multiplayer · ~2–4 months

View File

@@ -20,6 +20,10 @@ tests/run_all.sh
```
Or one at a time:
```bash
# Wolves: den, grace period, hunting, fire and walls, pack tactics
godot --headless --path . --script res://tests/test_wolves.gd
# Fishing and the mixed diet
godot --headless --path . --script res://tests/test_fishing_diet.gd
# Wildlife, melee, spear, bow, butchering, cooking + buffs, cloak, drops
godot --headless --path . --script res://tests/test_creatures_combat.gd
# Weather, shelter, snap building, support, doors, beds, regrowth

View File

@@ -159,7 +159,8 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
| Deer | 40 | 9 m/s | 16 m (30 m if you sprint) | flees, outruns you | 2-3 meat, 1-2 hide |
| Boar | 60 | 6.6 m/s | never | neutral; hit it and it charges and gores you for 14 every 1.6 s for 18 s, and flees below 30% HP | 3-4 meat, 1-2 hide |
- *(later)* **Wolf**: night predator, hunts in packs.
- **Wolf** *(built)*: 45 HP, 8.6 m/s, bites for 9 (every 1.2 s at most).
Butchering gives 1-2 meat and a wolf pelt. See 2.8a.
- `scripts/creatures/creature.gd` runs each animal's state machine (idle,
wander, flee, charge, dead). Steering is over the heightmap with no navmesh:
a probe ahead turns the animal away from water, steep drops, the world edge
@@ -174,6 +175,65 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
arrows stuck in it come back to you. Carcasses rot away after 5 min.
- Feathers lie in the meadows as ground pickups (70, regrowing like sticks).
### 2.8a Wolves and the den *(built)*
- **The den** (`den.gd`): a cave of piled boulders at the foot of the
mountain rim, chosen from the seed. It sits away from the sea and the
spawn, opens toward the valley, and has a clearing in front of its mouth.
It has a rock roof (it counts as shelter) and old bones on the floor.
- **Grace period** (`wolf_pack.gd`):
- Days 1-2: no wolves at all.
- Day 3: wolves move into the den and howl at night, with a toast
warning. They don't hunt yet.
- Night 4 onward: the pack hunts (night is 19:30 to 05:30, the same as
the bed). Pack size is 2, then 3 from day 6 and 4 from day 9. Wolves
you kill are replaced at the den by day, while you're away from it.
- **Temperament: wary pack hunters**, neither berserkers nor harmless:
- The pack circles you about 10 m out, and one wolf at a time darts in to
bite, every 4.5 s or so.
- A **lit campfire** within 8 m is a safe zone: they circle 14 m out
around the fire and never close in.
- A **held torch** makes them hesitate (2.5x longer between darts).
- **Walls work**: a bite needs a clear line from the wolf to you, so a
closed hut is safe. You also can't sleep while wolves prowl within 40 m.
- Hit one and the whole pack turns on you. Below 40% HP a wolf runs off
for the night.
- The **last wolf** of a pack is timid: it keeps its distance, backs off,
and won't start a fight.
- **By day** they rest in the den but defend it. Within 14 m they get up
and watch you; within 8 m (or if you hit one) they attack.
- At dawn they go home. Once out of your sight they slip straight back.
- Wolves glow-eyed at night (emissive eyes). Howls are synthesised.
- Animals now take a short committed detour when blocked, so they slide
round huts and rocks instead of turning back into them.
- **Wolf Cloak** (3 wolf pelt + 2 hide + 4 fiber, workbench): +25 warmth,
wet chill x0.35.
### 2.8b Fishing *(built)*
- **Fishing Rod** (2 sticks, 3 fiber, 1 flint at a workbench). LMB casts
the float in a lobbed arc. It has to land on lake water at least 0.5 m
deep: on land you're told so and nothing bites.
- A bite comes after 5-14 s, sooner at dawn and dusk (x0.6) and in the rain
(x0.8). The float bobs and dips; you have **1.5 s** to click and land the
fish, or it gets away and you wait again. Clicking with nothing biting
just reels in. Walking more than about 30 m off snaps the line, and
switching away from the rod reels in.
- Catch: perch (common) and trout (22%, or 40% at dawn and dusk). Grill
them at a campfire. A few fish swim visibly in the lake.
### 2.8c Mixed diet *(built)*
Every food belongs to a group: **meat** (raw meat 8, cooked 40), **fish**
(raw 8-12, grilled perch 30, grilled trout 45) or **greens** (berries 14,
mushroom 10, roasted mushroom 30). Each group has a level (0-100, starting
at 60) that runs down over about 1.5 days (1800 s). The HUD shows three
small Diet bars, and a low group blinks.
- **One group under 15**: Poor diet (need fish). No natural healing.
- **Two or more under 15**: Malnourished. Health drains 0.12/s and stamina
recovers at 70%. Living on berries alone keeps you full but kills you in
about a day and a half.
- **All three at 40 or more**: Balanced diet, healing x1.5.
- Diet is saved. After death each group is at least 30, so you don't
respawn straight into malnutrition.
### 2.9 Weapons & combat *(built in M1d)*
| Weapon | Recipe | Use | Damage |
|---|---|---|---|

View File

@@ -294,6 +294,37 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
carcass.queue_free()
return true
# --- fishing ----------------------------------------------------------------------
## Casts the held rod from `origin` along `dir`.
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
if not player.survival.use_stamina(float(rod.get("stamina", 4))):
notify.emit("Too tired")
return false
var d := dir.normalized()
d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level
world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0)))
return true
## Reels in. With a fish biting you land it; otherwise the line just comes in.
func reel(player: Node3D, world: Node3D) -> bool:
var b: Node3D = world.bobber
if b == null or not is_instance_valid(b):
return false
var caught := ""
if b.state == "biting":
caught = b.fish
world.end_fishing()
if caught.is_empty():
return false
if _give(player, caught, 1) == 0:
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
return true
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
func wear(player: Node3D, slot: int) -> bool:
var s = player.inventory.get_slot(slot)

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

127
scripts/fishing/bobber.gd Normal file
View File

@@ -0,0 +1,127 @@
extends Node3D
## A cast fishing float.
##
## flying -> it arcs out from the rod tip.
## floating -> it landed on deep enough water; a fish bites after a while
## (sooner at dawn and dusk, and in the rain).
## biting -> the float dips; you have BITE_WINDOW seconds to reel in
## (Actions.reel), or the fish gets away and you wait again.
## landed -> it fell on dry ground: nothing will bite. Reel in and try again.
## Walking too far away snaps the line.
##
## A line is drawn from the rod tip each frame (hands.gd gives the tip).
const BITE_WINDOW := 1.5
const MIN_DEPTH := 0.5 ## water this deep under the float, or no fish
const LINE_LENGTH := 26.0
var world: Node3D
var player: Node3D
var state := "flying"
var velocity := Vector3.ZERO
var fish := "" ## what's on the line while biting
var wait_left := 0.0
var bite_left := 0.0
var rng := RandomNumberGenerator.new()
var tip := Vector3.ZERO ## rod tip, world space (set by hands.gd)
var _t := 0.0
var _mesh: MeshInstance3D
var _line: MeshInstance3D
var _imesh: ImmediateMesh
func setup(w: Node3D, p: Node3D, from: Vector3, vel: Vector3, seed: int) -> void:
world = w
player = p
velocity = vel
position = from
tip = from
rng.seed = seed
_mesh = MeshInstance3D.new()
_mesh.mesh = ItemDB.mesh_for("bobber")
_mesh.material_override = ItemDB.item_material()
_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_mesh)
_imesh = ImmediateMesh.new()
_line = MeshInstance3D.new()
_line.mesh = _imesh
_line.top_level = true
_line.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var lm := StandardMaterial3D.new()
lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
lm.albedo_color = Color(0.9, 0.9, 0.85, 0.7)
lm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_line.material_override = lm
add_child(_line)
func _physics_process(delta: float) -> void:
step(delta)
func _process(_delta: float) -> void:
_draw_line()
## How long until the next bite (seconds).
func _next_wait() -> float:
var h: float = world.day_night.hours()
var k := 1.0
if (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5):
k *= 0.6 # fish feed at dawn and dusk
if world.weather.precip > 0.3:
k *= 0.8
return rng.randf_range(5.0, 14.0) * k
func _pick_fish() -> String:
var h: float = world.day_night.hours()
var trout := 0.4 if (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5) else 0.22
return "trout" if rng.randf() < trout else "perch"
func step(dt: float) -> void:
_t += dt
var terrain: Node3D = world.terrain
if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0:
Actions.notify.emit("The line snapped")
world.end_fishing()
return
match state:
"flying":
velocity.y -= 9.8 * dt
var p := global_position + velocity * dt
var ground: float = terrain.sample_height(p.x, p.z)
if p.y <= terrain.water_level and ground < terrain.water_level - MIN_DEPTH:
p.y = terrain.water_level
state = "floating"
wait_left = _next_wait()
elif p.y <= ground + 0.03:
p.y = ground + 0.03
state = "landed"
Actions.notify.emit("Cast into the water (deeper than this)" if ground < terrain.water_level else "Cast into the water")
global_position = p
"floating":
wait_left -= dt
_mesh.position.y = sin(_t * 2.2) * 0.012
if wait_left <= 0.0:
state = "biting"
fish = _pick_fish()
bite_left = BITE_WINDOW
Actions.notify.emit("Bite! Click to reel in")
"biting":
bite_left -= dt
# The float bobs hard and goes under.
_mesh.position.y = -0.06 + sin(_t * 18.0) * 0.03
if bite_left <= 0.0:
state = "floating"
fish = ""
wait_left = _next_wait() * 1.3
_mesh.position.y = 0.0
Actions.notify.emit("It got away")
func _draw_line() -> void:
_imesh.clear_surfaces()
_imesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
var a := tip
var b := global_position + Vector3(0, 0.1, 0)
for i in range(13):
var k := i / 12.0
var p := a.lerp(b, k)
p.y -= sin(k * PI) * 0.06 * a.distance_to(b) * (0.3 if state == "biting" else 1.0) # slack
_imesh.surface_add_vertex(p)
_imesh.surface_end()

View File

@@ -0,0 +1,126 @@
extends Node3D
## Fish you can see in the lake, so it's clear there's something to catch.
## Purely visual (the catch comes from bobber.gd).
##
## - A few swim slow loops just under the surface over water at least 0.8 m
## deep, including the shelf near the shore where you'll be standing.
## - Every few seconds one jumps: a leap out of the water and a spreading
## splash ring. That's the cue that tells you where to cast.
const SCALE := 1.6 ## drawn bigger than the item so they read at a distance
var terrain: Node3D
var _fish: Array = [] ## [MeshInstance3D, centre, radius, speed, phase, depth]
var _rng := RandomNumberGenerator.new()
var _jump_left := 3.0
var _jumping := -1 ## index of the fish mid-leap, or -1
var _jump_t := 0.0
var _rings: Array = [] ## [MeshInstance3D, age]
static var _ring_mesh: Mesh
func setup(t: Node3D, count: int = 14) -> void:
terrain = t
_rng.seed = t.noise_seed + 501
var lake: Vector2 = t.lake_center
var tries := 0
while _fish.size() < count and tries < 600:
tries += 1
var a := _rng.randf() * TAU
# Bias toward the outer, shallower ring so some swim near the shore.
var r: float = sqrt(_rng.randf()) * t.lake_radius * 0.7
var c := Vector2(lake.x + cos(a) * r, lake.y + sin(a) * r)
var loop := _rng.randf_range(1.2, 3.0)
var ok := true
for k in range(8):
var q := c + Vector2(cos(k * TAU / 8.0), sin(k * TAU / 8.0)) * loop
if t.sample_height(q.x, q.y) > t.water_level - 0.8:
ok = false
break
if not ok:
continue
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for("trout" if _rng.randf() < 0.3 else "perch")
mi.material_override = ItemDB.item_material()
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.scale = Vector3.ONE * SCALE
add_child(mi)
_fish.append([mi, c, loop, _rng.randf_range(0.25, 0.5) * (1.0 if _rng.randf() < 0.5 else -1.0),
_rng.randf() * TAU, _rng.randf_range(0.2, 0.35)])
## Where fish `i` is in its loop at time `t`.
func _swim_pos(i: int, t: float) -> Vector3:
var f: Array = _fish[i]
var a: float = t * f[3] + f[4]
var p: Vector2 = (f[1] as Vector2) + Vector2(cos(a), sin(a)) * float(f[2])
return Vector3(p.x, terrain.water_level - f[5] + sin(t * 1.3 + f[4]) * 0.04, p.y)
func _process(delta: float) -> void:
var t := Time.get_ticks_msec() / 1000.0
for i in range(_fish.size()):
if i == _jumping:
continue
var f: Array = _fish[i]
var mi: MeshInstance3D = f[0]
mi.position = _swim_pos(i, t)
var a: float = t * f[3] + f[4]
# Face along the loop (fish mesh's head is +X), with a little wiggle.
var tangent := Vector2(-sin(a), cos(a)) * signf(f[3])
mi.rotation = Vector3(0, atan2(-tangent.y, tangent.x) + sin(t * 6.0 + f[4]) * 0.15, 0)
_update_jump(delta, t)
_update_rings(delta)
func _update_jump(dt: float, t: float) -> void:
if _fish.is_empty():
return
if _jumping < 0:
_jump_left -= dt
if _jump_left <= 0.0:
_jumping = _rng.randi() % _fish.size()
_jump_t = 0.0
_ring(_swim_pos(_jumping, t))
return
# A 0.8 s arc, half a metre up, nose up then nose down.
_jump_t += dt
var k := _jump_t / 0.8
var mi: MeshInstance3D = _fish[_jumping][0]
var base := _swim_pos(_jumping, t)
mi.position = base + Vector3(0, sin(clampf(k, 0.0, 1.0) * PI) * 0.55 + 0.25, 0)
mi.rotation.z = lerpf(1.0, -1.0, clampf(k, 0.0, 1.0))
if k >= 1.0:
_ring(base)
mi.rotation.z = 0.0
_jumping = -1
_jump_left = _rng.randf_range(3.0, 8.0)
## A splash ring that spreads and fades on the surface.
func _ring(at: Vector3) -> void:
if _ring_mesh == null:
var tm := TorusMesh.new()
tm.inner_radius = 0.42
tm.outer_radius = 0.5
tm.rings = 24
tm.ring_segments = 4
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(0.9, 0.95, 1.0, 0.8)
tm.material = m
_ring_mesh = tm
var mi := MeshInstance3D.new()
mi.mesh = _ring_mesh
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.position = Vector3(at.x, terrain.water_level + 0.02, at.z)
mi.scale = Vector3(0.4, 0.05, 0.4)
add_child(mi)
_rings.append([mi, 0.0])
func _update_rings(dt: float) -> void:
for r in _rings.duplicate():
r[1] += dt
var mi: MeshInstance3D = r[0]
var s: float = 0.4 + r[1] * 1.6
mi.scale = Vector3(s, 0.05, s)
mi.transparency = clampf(r[1] / 1.6, 0.0, 1.0)
if r[1] >= 1.6:
mi.queue_free()
_rings.erase(r)

View File

@@ -65,6 +65,14 @@ static func build(key: String) -> ArrayMesh:
"bow": m = _bow()
"arrow": m = _arrow()
"hide_cloak": m = _cloak()
"wolf_pelt": m = _pelt(WOLF_FUR)
"wolf_cloak": m = _cloak(WOLF_FUR)
"fishing_rod": m = _rod()
"bobber": m = _bobber()
"perch": m = _fish(PERCH, 0.16, false)
"trout": m = _fish(TROUT, 0.24, false)
"grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true)
"berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false)
_:
@@ -458,12 +466,74 @@ static func _arrow() -> ArrayMesh:
P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(d.y, -d.x, 0), FLETCH)
return P._commit(st)
static func _cloak() -> ArrayMesh:
const WOLF_FUR := Color(0.5, 0.5, 0.52)
static func _pelt(col: Color) -> ArrayMesh:
var st := P._begin()
var r := _rng(149)
_box(st, Vector3(0, 0.02, 0), Vector3(0.42, 0.035, 0.24), col, Basis(Vector3.UP, 0.15))
_box(st, Vector3(0.04, 0.05, 0.01), Vector3(0.3, 0.035, 0.18), _board(r, col), Basis(Vector3.UP, -0.25))
_box(st, Vector3(-0.2, 0.03, 0.05), Vector3(0.12, 0.03, 0.05), P._mul(col, 0.8), Basis(Vector3.UP, 0.7)) # tail
return P._commit(st)
static func _cloak(col: Color = HIDE) -> ArrayMesh:
var st := P._begin()
var r := _rng(139)
# Folded cloak: a stack of fur with a bone clasp.
for i in range(3):
_box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, HIDE), Basis(Vector3.UP, (i - 1) * 0.08))
_box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR, 1.1))
_box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, col), Basis(Vector3.UP, (i - 1) * 0.08))
_box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR if col == HIDE else col, 1.1))
_box(st, Vector3(0.1, 0.2, 0.05), Vector3(0.05, 0.03, 0.03), BONE)
return P._commit(st)
# --- fishing ------------------------------------------------------------------
const PERCH := Color(0.45, 0.52, 0.30)
const TROUT := Color(0.50, 0.55, 0.58)
const GRILLED := Color(0.55, 0.36, 0.18)
const FLOAT_RED := Color(0.85, 0.15, 0.12)
## Rod along +Y from the grip, a tip ring at the top. Rod tip is at
## (0, ROD_TIP, 0) (hands.gd draws the line from there).
const ROD_TIP := 1.15
static func _rod() -> ArrayMesh:
var st := P._begin()
var r := _rng(151)
P._cylinder(st, Vector3(0, -0.15, 0), Vector3(0, ROD_TIP, 0), 0.02, 0.006, 5, HANDLE, r, -1.0, HANDLE)
_box(st, Vector3(0, 0.0, 0), Vector3(0.05, 0.16, 0.05), BINDING)
# A simple wooden reel with line wound on it.
P._cylinder(st, Vector3(-0.05, 0.12, 0.04), Vector3(0.05, 0.12, 0.04), 0.045, 0.045, 7, PLANK_DARK, r, 0.0, LINEN)
return P._commit(st)
static func _bobber() -> ArrayMesh:
var st := P._begin()
var r := _rng(157)
var n := FastNoiseLite.new()
# Big enough to spot 10 m out on the water.
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.08, 0.06, 0.08), n, 0.0, Color(0.95, 0.95, 0.92), r, 1)
P._blob(st, Vector3(0, 0.09, 0), Vector3(0.07, 0.05, 0.07), n, 0.0, FLOAT_RED, r, 1)
P._cylinder(st, Vector3(0, 0.11, 0), Vector3(0, 0.24, 0), 0.01, 0.007, 4, FLOAT_RED, r)
return P._commit(st)
## A fish lying on its side along X, head at +X. `len` is half its length.
static func _fish(col: Color, len: float, grilled: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(163 + int(len * 100.0))
var n := FastNoiseLite.new()
n.seed = 163
var body := GRILLED.lerp(col, 0.25) if grilled else col
P._blob(st, Vector3(0, len * 0.3, 0), Vector3(len, len * 0.32, len * 0.14), n, 0.05, body, r, 1)
P._blob(st, Vector3(len * 0.1, len * 0.22, 0), Vector3(len * 0.75, len * 0.18, len * 0.12), n, 0.05,
P._mul(body, 1.25) if not grilled else P._mul(body, 0.8), r, 1) # pale belly / char
# Tail fin.
var t0 := Vector3(-len * 0.9, len * 0.3, 0)
var fin := P._mul(body, 0.8)
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, 1), fin)
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, -1), fin)
# Dorsal fin and an eye.
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, 1), fin)
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, -1), fin)
if not grilled:
for s in [-1.0, 1.0]:
P._blob(st, Vector3(len * 0.75, len * 0.38, s * len * 0.11), Vector3.ONE * len * 0.06, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
return P._commit(st)

View File

@@ -12,6 +12,7 @@ signal prompt_changed(text: String)
const Campfire = preload("res://scripts/building/campfire.gd")
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const ItemMeshesScript = preload("res://scripts/items/item_meshes.gd")
const REACH := 3.2
const PLACE_REACH := 6.0
const SWING_TIME := 0.5
@@ -173,8 +174,24 @@ func _physics_process(delta: float) -> void:
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
elif def.get("weapon", {}).has("throw"):
_set_prompt("[LMB] Thrust [RMB] Throw")
elif def.has("fishing"):
var b: Node3D = player.get_parent().bobber
if b == null or not is_instance_valid(b):
_set_prompt("[LMB] Cast into the lake")
else:
match b.state:
"biting": _set_prompt("Bite! [LMB] Reel in now")
"floating": _set_prompt("Waiting for a bite... [LMB] Reel in")
_: _set_prompt("[LMB] Reel in")
else:
_set_prompt("")
# The line runs from the rod tip; switching away from the rod reels in.
var bob: Node3D = player.get_parent().bobber
if bob != null and is_instance_valid(bob):
if def.has("fishing"):
bob.tip = rod_tip()
else:
player.get_parent().end_fishing()
if _drawing:
if not def.has("bow"):
_drawing = false # switched away mid-draw
@@ -252,9 +269,32 @@ func _unhandled_input(event: InputEvent) -> void:
Actions.notify.emit("Can't place that here")
elif _held_def().has("bow"):
_start_draw()
elif _held_def().has("fishing"):
_fish_click()
else:
swing()
# --- fishing ----------------------------------------------------------------------
func _fish_click() -> void:
var w: Node3D = player.get_parent()
if w.bobber != null and is_instance_valid(w.bobber):
Actions.reel(player, w)
return
if _swing_cd > 0.0:
return
var a := _aim()
if Actions.cast_line(player, rod_tip(), a[1], w):
_swing_cd = 0.6
var tw := create_tween()
var back := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(40)), _view_rest.origin)
tw.tween_property(_view, "transform", back, 0.12)
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
## World position of the held rod's tip (where the line starts).
func rod_tip() -> Vector3:
return _view.global_transform * Vector3(0, ItemMeshesScript.ROD_TIP, 0)
# --- bow and spear --------------------------------------------------------------
func _aim() -> Array:

View File

@@ -42,6 +42,35 @@ var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
signal hurt(amount: float, from: Vector3)
const HURT_NO_REGEN := 6.0
## Mixed diet: each food group (meat, fish, greens) has a level that runs
## down over about a day and a half. Food from a group tops it up.
## - One group low: you don't heal naturally ("Poor diet").
## - Two or more low: Malnourished. Health slowly drains, stamina recovers
## slower. Living on berries alone will kill you.
## - All three kept up: Balanced diet, healing x1.5.
const DIET_GROUPS := ["meat", "fish", "plants"]
const DIET_NAMES := {"meat": "meat", "fish": "fish", "plants": "greens"}
const DIET_DRAIN := 100.0 / 1800.0
const DIET_LOW := 15.0
const DIET_GOOD := 40.0
const DIET_START := 60.0
const MALNOURISHED_DRAIN := 0.12
var diet := {"meat": DIET_START, "fish": DIET_START, "plants": DIET_START}
## The groups that are running low right now.
func diet_lacking() -> Array:
var out := []
for g in DIET_GROUPS:
if float(diet[g]) < DIET_LOW:
out.append(g)
return out
func balanced_diet() -> bool:
for g in DIET_GROUPS:
if float(diet[g]) < DIET_GOOD:
return false
return true
var player: CharacterBody3D
var world: Node3D
var _stamina_wait := 0.0
@@ -85,7 +114,13 @@ func step(dt: float) -> void:
if _stamina_wait > 0.0:
_stamina_wait -= dt
else:
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen") * dt, MAX)
var malnourished := diet_lacking().size() >= 2
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
* (0.7 if malnourished else 1.0) * dt, MAX)
for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * dt, 0.0)
var lacking := diet_lacking()
for id in buffs.keys():
buffs[id].left -= dt
@@ -98,10 +133,13 @@ func step(dt: float) -> void:
dmg += 0.6
if warmth < 12.0:
dmg += 0.5
if lacking.size() >= 2:
dmg += MALNOURISHED_DRAIN
if dmg > 0.0:
health = maxf(health - dmg * dt, 0.0)
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0:
health = minf(health + 0.5 * buff_mult("health_regen") * dt, MAX)
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty():
var diet_mult := 1.5 if balanced_diet() else 1.0
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX)
_update_statuses()
changed.emit()
@@ -123,6 +161,14 @@ func _update_statuses() -> void:
statuses.append("Hungry")
if wet > 0.0:
statuses.append("Wet")
var lacking := diet_lacking()
if not lacking.is_empty():
var need: Array[String] = []
for g in lacking:
need.append(DIET_NAMES[g])
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
elif balanced_diet():
statuses.append("Balanced diet")
if sheltered:
statuses.append("Sheltered")
if rested > 0.0:
@@ -145,6 +191,11 @@ func eat(food: Dictionary) -> void:
health = clampf(health + float(food.get("health", 0)), minf(health, 1.0), MAX)
if food.has("buff"):
add_buff(food.buff)
var d: Dictionary = food.get("diet", {})
for g in d:
if diet.has(g):
diet[g] = minf(float(diet[g]) + float(d[g]), MAX)
_update_statuses()
changed.emit()
func soak() -> void:
@@ -168,6 +219,9 @@ func reset_after_death() -> void:
warmth = 60.0
wet = 0.0
buffs.clear()
# Don't respawn straight back into malnutrition.
for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]), 30.0)
changed.emit()
## Takes a hit (a boar, later wolves). Pauses natural healing briefly.
@@ -198,7 +252,7 @@ func add_buff(b: Dictionary) -> void:
func to_data() -> Dictionary:
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
"buffs": buffs.duplicate(true)}
"buffs": buffs.duplicate(true), "diet": diet.duplicate()}
func load_data(d: Dictionary) -> void:
health = float(d.get("health", health))
@@ -208,5 +262,9 @@ func load_data(d: Dictionary) -> void:
wet = float(d.get("wet", 0.0))
rested = float(d.get("rested", 0.0))
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
# Older saves have no diet: start it where a new world does.
var sd: Dictionary = d.get("diet", {})
for g in DIET_GROUPS:
diet[g] = float(sd.get(g, DIET_START))
dead = false
changed.emit()

View File

@@ -16,6 +16,7 @@ var _toasts: VBoxContainer
var _screen: Control
var _menu: Control
var _bars: Dictionary = {}
var _diet_bars: Dictionary = {}
var _status: Label
var _clock: Label
var _hurt: ColorRect
@@ -142,6 +143,29 @@ func _build_stats(root: Control) -> void:
row.add_child(bar)
box.add_child(row)
_bars[spec[0]] = bar
# Diet: three short bars (meat, fish, greens), so a gap shows early.
var drow := HBoxContainer.new()
drow.add_theme_constant_override("separation", 6)
drow.mouse_filter = Control.MOUSE_FILTER_IGNORE
var dl := UITheme.label("Diet", 14, UITheme.TEXT_DIM)
dl.custom_minimum_size = Vector2(64, 0)
drow.add_child(dl)
for spec in [["meat", "Meat", Color(0.75, 0.3, 0.25)], ["fish", "Fish", Color(0.35, 0.6, 0.85)], ["plants", "Greens", Color(0.45, 0.75, 0.3)]]:
var cell := VBoxContainer.new()
cell.add_theme_constant_override("separation", 0)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var bar := ProgressBar.new()
bar.custom_minimum_size = Vector2(58, 8)
bar.show_percentage = false
bar.max_value = 100.0
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 3, 0))
bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 3, 0))
cell.add_child(bar)
cell.add_child(UITheme.label(spec[1], 11, UITheme.TEXT_DIM))
drow.add_child(cell)
_diet_bars[spec[0]] = bar
box.add_child(drow)
_clock = UITheme.label("", 18)
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
@@ -154,6 +178,10 @@ func _process(_d: float) -> void:
var s = player.survival
for k in _bars:
_bars[k].value = s.get(k)
for g in _diet_bars:
_diet_bars[g].value = s.diet[g]
# A group running low blinks, so you notice before it hurts.
_diet_bars[g].modulate.a = 0.45 + 0.55 * absf(sin(Time.get_ticks_msec() / 300.0)) if s.diet[g] < s.DIET_LOW else 1.0
# Warmth bar shifts from icy blue (cold) to orange (warm).
var w: float = s.warmth / 100.0
var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w)

View File

@@ -10,6 +10,7 @@ const CONTROLS := [
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
["Right click (holding food)", "Eat"], ["Right click (spear / cloak)", "Throw / wear"],
["Hold left click (bow)", "Draw, release to shoot"],
["Left click (fishing rod)", "Cast / reel in when the float dips"],
["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"],
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],

View File

@@ -759,12 +759,17 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
## half width]. Set by world.gd whenever floors are built or removed; only the
## grass tiles around old and new floors are rebuilt.
var _grass_cuts: Array = []
## Permanent cuts (the wolf den floor): [transform, half width x, half depth z].
var static_cuts: Array = []
func set_grass_cuts(xforms: Array, half: float) -> void:
var old := _grass_cuts
_grass_cuts = []
for xf: Transform3D in xforms:
_grass_cuts.append([xf.affine_inverse(), xf.origin, half])
_grass_cuts.append([xf.affine_inverse(), xf.origin, half, half])
for c in static_cuts:
var xf: Transform3D = c[0]
_grass_cuts.append([xf.affine_inverse(), xf.origin, float(c[1]), float(c[2])])
var touched := {}
for c in old + _grass_cuts:
var p: Vector3 = c[1]
@@ -783,7 +788,7 @@ func set_grass_cuts(xforms: Array, half: float) -> void:
static func _under_cut(cuts: Array, x: float, z: float) -> bool:
for c in cuts:
var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z)
if absf(l.x) < c[2] and absf(l.z) < c[2]:
if absf(l.x) < c[2] and absf(l.z) < c[3]:
return true
return false

View File

@@ -47,6 +47,9 @@ var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
var build: Node ## build_system.gd: snapping, support, shelter
var creatures: Node3D ## spawner.gd: wildlife around the player
var _dropped: Node3D ## arrows, spears and other dropped items
var den: Node3D ## den.gd: the wolves' cave at the foot of the rim
var bobber: Node3D ## bobber.gd: the cast fishing float, if any
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
var _loading := false ## applying a save: defer support recompute
@@ -193,6 +196,10 @@ func sleep_block_reason(bed: Node3D) -> String:
return "You can only sleep at night (after 19:30)"
if not build.sheltered_at(bed.global_position):
return "Your bed needs a roof and walls around it"
if wolves != null:
for w in wolves.alive():
if w.state in ["stalk", "lunge", "defend", "retreat"] and w.global_position.distance_to(bed.global_position) < 40.0:
return "You can't sleep with wolves prowling outside"
return ""
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
@@ -227,6 +234,18 @@ func _on_terrain_ready() -> void:
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
player.water_level = terrain.water_level
props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn)
# The wolf den: part of the scenery, so it's there from day 1 (empty).
den = preload("res://scripts/creatures/den.gd").new()
den.name = "WolfDen"
add_child(den)
if den.place(self, props._rock_mat):
props.static_cuts.append([den.global_transform, 2.2, 3.4])
_update_grass_cuts()
if DisplayServer.get_name() != "headless":
var fish := preload("res://scripts/fishing/lake_fish.gd").new()
fish.name = "LakeFish"
add_child(fish)
fish.setup(terrain)
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
if menu_mode:
return
@@ -242,6 +261,11 @@ func _on_terrain_ready() -> void:
if not "--no-creatures" in OS.get_cmdline_user_args():
creatures.populate()
creatures.enabled = true
if den.found:
wolves = preload("res://scripts/creatures/wolf_pack.gd").new()
wolves.name = "Wolves"
add_child(wolves)
wolves.setup(self, den)
_maybe_capture()
func _process(delta: float) -> void:
@@ -474,6 +498,19 @@ func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
pk.global_transform = xf
return pk
## Casts a fishing float (Actions validated it). One at a time.
func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D:
end_fishing()
bobber = preload("res://scripts/fishing/bobber.gd").new()
bobber.setup(self, p, origin, vel, randi())
add_child(bobber)
return bobber
func end_fishing() -> void:
if bobber != null and is_instance_valid(bobber):
bobber.queue_free()
bobber = null
## Fires an arrow or thrown spear (Actions validated it).
func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D:
var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new()
@@ -643,6 +680,103 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
hud._menu.close()
await _capture_weather(prefix, cam)
await _capture_creatures(prefix, cam)
await _capture_wolves_fishing(prefix, cam)
## The den by day with the pack resting, wolves stalking at night, and
## fishing from the lake shore.
func _capture_wolves_fishing(prefix: String, cam: Camera3D) -> void:
if wolves == null:
return
for c in creatures.get_children():
c.queue_free()
wolves.enabled = false
day_night.day = 5
day_night.time = 10.0 / 24.0
day_night.apply(true)
wolves.step(0.1)
var out: Vector3 = (den.mouth_pos() - den.position).normalized()
var eye: Vector3 = den.position + Basis(Vector3.UP, 0.35) * out * 11.0
eye.y = terrain.sample_height(eye.x, eye.z) + 1.7
player.select(0)
_aim_camera(cam, eye, den.position + Vector3(0, 1.0, 0))
for w in wolves.alive():
w.set_physics_process(false)
for _i in range(30):
w._animate(0.1)
await get_tree().create_timer(1.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_den.png" % prefix)
print("[world] saved %s_den.png" % prefix)
# Night: the pack circling you in the open.
var site: Vector3 = build.debug_find_site(_spawn + Vector3(-10, 0, 10), 6.0)
site.y = terrain.sample_height(site.x, site.z)
day_night.time = 22.5 / 24.0
day_night.apply(true)
var live: Array = wolves.alive()
for i in range(live.size()):
var a := -0.5 + i * 0.7
var p: Vector3 = site + Vector3(sin(a), 0, -cos(a)) * (7.0 + i * 1.5)
live[i].global_position = Vector3(p.x, terrain.sample_height(p.x, p.z), p.z)
live[i].state = "stalk"
var to: Vector3 = site - p
live[i].yaw = atan2(-to.x, -to.z)
live[i].rotation.y = live[i].yaw
live[i]._visual.position.y = 0.0
for l in live[i]._legs:
l.rotation.x = 0.0
player.inventory.set_slot(1, {"id": "torch", "count": 1})
player.select(1)
var e2: Vector3 = site + Vector3(0, 1.7, 0)
_aim_camera(cam, e2, site + Vector3(0, 0.6, -8.0))
await get_tree().create_timer(1.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_wolves_night.png" % prefix)
print("[world] saved %s_wolves_night.png" % prefix)
for w in live:
w.global_position = den.slot_pos(w.slot)
# Fishing at the lake in the evening light, a fish on the line.
day_night.time = 18.2 / 24.0
day_night.apply(true)
var lake: Vector2 = terrain.lake_center
var x: float = lake.x
while terrain.sample_height(x, lake.y) < terrain.water_level + 0.4 and x < lake.x + 60.0:
x += 0.5
var shore := Vector3(x + 1.0, 0, lake.y)
shore.y = terrain.sample_height(shore.x, shore.z)
player.inventory.set_slot(2, {"id": "fishing_rod", "count": 1})
player.select(2)
var fe: Vector3 = shore + Vector3(0, 1.7, 0)
# Midday, looking at the nearest fish, one mid-jump.
var lf: Node = get_node_or_null("LakeFish")
if lf != null and not lf._fish.is_empty():
day_night.time = 12.5 / 24.0
day_night.apply(true)
var t_now := Time.get_ticks_msec() / 1000.0
var best := 0
for i in range(lf._fish.size()):
if lf._swim_pos(i, t_now).distance_to(fe) < lf._swim_pos(best, t_now).distance_to(fe):
best = i
_aim_camera(cam, fe, lf._swim_pos(best, t_now))
lf._jump_left = 0.4
await get_tree().create_timer(0.75).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_lake_fish.png" % prefix)
print("[world] saved %s_lake_fish.png (fish %.0f m away)" % [prefix, lf._swim_pos(best, t_now).distance_to(fe)])
day_night.time = 18.2 / 24.0
day_night.apply(true)
_aim_camera(cam, fe, Vector3(lake.x + 6.0, terrain.water_level - 1.5, lake.y + 2.0))
await get_tree().create_timer(0.3).timeout
start_fishing(player, player.hands.rod_tip(), Vector3.ZERO)
bobber.set_physics_process(false)
bobber.global_position = Vector3(lake.x + 9.0, terrain.water_level, lake.y + 1.5)
bobber.state = "biting"
await get_tree().create_timer(1.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_fishing.png" % prefix)
print("[world] saved %s_fishing.png" % prefix)
end_fishing()
## Wildlife views: a rabbit, a deer and a boar in a meadow, the bow drawn on
## a deer, and a carcass with an arrow in it.

View File

@@ -4,7 +4,7 @@
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
cd "$(dirname "$0")/.." || exit 1
status=0
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_terrain_edges test_boundary; do
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_fishing_diet test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever,

262
tests/test_fishing_diet.gd Normal file
View File

@@ -0,0 +1,262 @@
extends SceneTree
## Fishing (rod, casting, bites, landing and losing fish, grilling) and the
## mixed diet (food groups, poor diet, malnutrition, balanced diet, saving).
## Run: godot --headless --path . --script res://tests/test_fishing_diet.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
var _toasts: Array[String] = []
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _spawn_world() -> Node3D:
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(5):
await physics_frame
return w
func _run() -> void:
A = root.get_node("Actions")
S = root.get_node("Saves")
DB = root.get_node("ItemDB")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
var id: String = S.create_world("Fish", 1337)
S.current_id = id
var world: Node3D = await _spawn_world()
await _body(world)
check(_finished, "test ran to the end (no script errors)")
S.delete_world(id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _hold(player: Node, id: String) -> void:
var inv: Inventory = player.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and s.id == id:
var tmp = inv.get_slot(0)
inv.set_slot(0, s)
inv.set_slot(i, tmp)
break
player.select(0)
## Steps the float until it reaches `want` (or `secs` pass). Returns it.
func _bob_until(world: Node3D, want: String, secs: float) -> Node3D:
var t := 0.0
while t < secs:
var b: Node3D = world.bobber
if b == null or not is_instance_valid(b):
return null
if b.state == want:
return b
b.set_physics_process(false)
b.step(0.05)
t += 0.05
return world.bobber
func _cast(world: Node3D, player: Node3D, toward: Vector3) -> bool:
var origin: Vector3 = player.global_position + Vector3(0, 1.6, 0)
return A.cast_line(player, origin, toward - origin, world)
func _body(world: Node3D) -> void:
var player: CharacterBody3D = world.get_node("Player")
var sv: Node = player.survival
var inv: Inventory = player.inventory
var terrain: Node3D = world.get_node("Terrain")
player.set_physics_process(false)
sv.set_process(false)
world.day_night.frozen = true
world.weather.force("clear")
world.creatures.enabled = false
world.day_night.time = 0.5
world.day_night.apply(true)
print("the rod")
var bench: Node = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), player.global_position + Vector3(2, -1.5, 0)))
bench.global_position.y = terrain.sample_height(bench.global_position.x, bench.global_position.z)
inv.add("stick", 2)
inv.add("fiber", 3)
inv.add("flint", 1)
check(A.craft(player, "fishing_rod") and inv.count("fishing_rod") == 1, "a fishing rod from 2 sticks, 3 fiber and a flint")
world.remove_piece(bench)
_hold(player, "fishing_rod")
check(A.melee_stats(player).armed == false, "(a rod isn't a weapon)")
# Stand on the lake's east shore, looking out over the water.
var lake: Vector2 = terrain.lake_center
var x: float = lake.x
while terrain.sample_height(x, lake.y) < terrain.water_level + 0.4 and x < lake.x + 60.0:
x += 0.5
var shore := Vector3(x + 1.0, terrain.sample_height(x + 1.0, lake.y), lake.y)
player.global_position = shore
var out := Vector3(lake.x, terrain.water_level, lake.y)
var inland := shore + Vector3(12, 0, 0)
inland.y = terrain.sample_height(inland.x, inland.z)
print("casting")
sv.stamina = 100.0
check(_cast(world, player, out) and world.bobber != null, "cast the line")
check(sv.stamina < 100.0, "casting costs a little stamina")
var b := _bob_until(world, "floating", 5.0)
check(b != null and b.state == "floating" and absf(b.global_position.y - terrain.water_level) < 0.01, "the float lands on the lake")
check(terrain.sample_height(b.global_position.x, b.global_position.z) < terrain.water_level - 0.5, "over deep enough water (%.1f m)" % (terrain.water_level - terrain.sample_height(b.global_position.x, b.global_position.z)))
b = _bob_until(world, "biting", 40.0)
check(b != null and b.state == "biting" and _toasts.has("Bite! Click to reel in"), "a fish bites")
var fish0 := inv.count("perch") + inv.count("trout")
check(A.reel(player, world) and inv.count("perch") + inv.count("trout") == fish0 + 1, "reel in quickly and you land it")
check(world.bobber == null, "and the line comes in")
print("losing one")
_cast(world, player, out)
_bob_until(world, "floating", 5.0)
b = _bob_until(world, "biting", 40.0)
_toasts.clear()
b.step(b.BITE_WINDOW + 0.1)
check(b.state == "floating" and _toasts.has("It got away"), "too slow and it gets away")
fish0 = inv.count("perch") + inv.count("trout")
check(not A.reel(player, world) and inv.count("perch") + inv.count("trout") == fish0, "reeling in with nothing biting catches nothing")
print("bad casts")
_toasts.clear()
player.global_position = shore
_cast(world, player, inland + Vector3(10, 0, 0))
b = _bob_until(world, "landed", 5.0)
check(b != null and b.state == "landed" and _toasts.any(func(t): return t.begins_with("Cast into the water")), "a cast onto dry land tells you so")
b.step(60.0)
check(b.state == "landed", "and nothing ever bites on land")
A.reel(player, world)
_cast(world, player, out)
_bob_until(world, "floating", 5.0)
player.global_position = shore + Vector3(45, 0, 0)
world.bobber.step(0.05)
check(world.bobber == null, "walk off and the line snaps")
player.global_position = shore
_cast(world, player, out)
_bob_until(world, "floating", 5.0)
inv.add("berries", 1)
player.select(5)
for _i in range(3):
await physics_frame
check(world.bobber == null, "switching away from the rod reels in")
_hold(player, "fishing_rod")
print("when they bite")
_cast(world, player, out)
b = _bob_until(world, "floating", 5.0)
var noon := 0.0
var dawn := 0.0
world.day_night.time = 12.0 / 24.0
for _i in range(300):
noon += b._next_wait()
world.day_night.time = 6.5 / 24.0
for _i in range(300):
dawn += b._next_wait()
check(dawn < noon * 0.75, "fish bite sooner at dawn (%.1f s vs %.1f s at noon)" % [dawn / 300.0, noon / 300.0])
var trout := 0
for _i in range(400):
if b._pick_fish() == "trout":
trout += 1
check(trout > 100 and trout < 220, "trout are the rarer catch (%d in 400 at dawn)" % trout)
A.reel(player, world)
world.day_night.time = 0.5
print("grilling")
inv.add("perch", 1)
inv.add("trout", 1)
var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), shore + Vector3(2, 0, 0)))
fire.global_position.y = terrain.sample_height(fire.global_position.x, fire.global_position.z)
check(A.craft(player, "grilled_perch") and A.craft(player, "grilled_trout"), "grill perch and trout at a campfire")
world.remove_piece(fire)
print("diet")
check(sv.diet.meat > 59.0 and sv.diet.fish > 59.0 and sv.diet.plants > 59.0, "a new world starts you reasonably fed (%s)" % [sv.diet])
sv.hunger = 50.0
inv.add("berries", 1)
_hold(player, "berries")
var p0: float = sv.diet.plants
A.eat(player, 0)
check(sv.diet.plants > p0 and sv.diet.meat < 60.0 + 0.01, "berries count as greens, not meat")
_hold(player, "grilled_trout")
sv.diet.fish = 20.0
var f0: float = sv.diet.fish
A.eat(player, 0)
check(sv.diet.fish >= f0 + 44.0, "grilled trout is fish")
sv.diet = {"meat": 60.0, "fish": 60.0, "plants": 60.0}
sv.hunger = 90.0
sv.warmth = 70.0
for _i in range(900):
sv.step(1.0)
check(sv.diet.meat < 15.0 + 0.5 and sv.diet.meat > 5.0, "each group runs down over about a day (meat %.0f after 15 min)" % sv.diet.meat)
# One group low: no healing.
sv.diet = {"meat": 50.0, "fish": 5.0, "plants": 50.0}
sv.hunger = 90.0
sv.health = 60.0
sv._hurt_cd = 0.0
sv.step(10.0)
check(sv.health == 60.0, "missing one food group stops natural healing")
check(sv.statuses.any(func(s): return s.begins_with("Poor diet (need fish")), "and the HUD says what you're missing (%s)" % [sv.statuses])
# Two low: health drains.
sv.diet = {"meat": 5.0, "fish": 5.0, "plants": 50.0}
sv.step(10.0)
check(sv.health < 60.0, "missing two makes you Malnourished: health drains (%.1f)" % sv.health)
check(sv.statuses.any(func(s): return s.begins_with("Malnourished")), "(%s)" % [sv.statuses])
# Balanced heals faster than merely adequate.
sv.diet = {"meat": 25.0, "fish": 25.0, "plants": 25.0}
sv.health = 50.0
sv.step(10.0)
var adequate: float = sv.health - 50.0
sv.diet = {"meat": 70.0, "fish": 70.0, "plants": 70.0}
sv.health = 50.0
sv.step(10.0)
var balanced: float = sv.health - 50.0
check(balanced > adequate * 1.4 and sv.statuses.has("Balanced diet"), "a balanced diet heals faster (%.1f vs %.1f per 10 s)" % [balanced, adequate])
# Living on berries alone eventually kills you.
var deaths := [0]
sv.died.connect(func(): deaths[0] += 1)
sv.diet = {"meat": 60.0, "fish": 60.0, "plants": 60.0}
sv.health = 100.0
sv.hunger = 100.0
inv.add("berries", 30)
var hours := 0.0
var min_hunger := 100.0
while deaths[0] == 0 and hours < 72.0:
for _i in range(60):
sv.step(1.0)
if deaths[0] == 0:
min_hunger = minf(min_hunger, sv.hunger)
if sv.hunger < 85.0:
_hold(player, "berries")
A.eat(player, 0)
hours += 60.0 / 50.0 # 60 s is 1.2 in-game hours
check(deaths[0] == 1 and min_hunger > 50.0, "berries alone keep you full but kill you in the end (after %.0f in-game hours, food never below %.0f)" % [hours, min_hunger])
check(hours > 24.0, "slowly, though: over a day, so there's time to notice")
print("save + load")
sv.diet = {"meat": 33.0, "fish": 44.0, "plants": 55.0}
world.save_now()
world.queue_free()
await process_frame
await process_frame
world = await _spawn_world()
sv = world.get_node("Player").survival
check(absf(sv.diet.meat - 33.0) < 0.5 and absf(sv.diet.fish - 44.0) < 0.5 and absf(sv.diet.plants - 55.0) < 0.5, "diet levels are saved")
sv.load_data({"health": 80.0})
check(sv.diet.meat == 60.0 and sv.diet.fish == 60.0, "older saves start with a reasonable diet")
world.queue_free()
_finished = true

280
tests/test_wolves.gd Normal file
View File

@@ -0,0 +1,280 @@
extends SceneTree
## Wolves: the den, the grace period, hunting at night, fire and walls as
## protection, pack tactics, timid lone wolves, the dawn retreat, den
## defence, pelts and the wolf cloak.
## Run: godot --headless --path . --script res://tests/test_wolves.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
var _toasts: Array[String] = []
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
A = root.get_node("Actions")
S = root.get_node("Saves")
DB = root.get_node("ItemDB")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
var id: String = S.create_world("Wolves", 1337)
S.current_id = id
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(5):
await physics_frame
await _body(world)
check(_finished, "test ran to the end (no script errors)")
S.delete_world(id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _set_time(world: Node3D, day: int, hours: float) -> void:
world.day_night.day = day
world.day_night.time = hours / 24.0
world.day_night.apply(true)
## Runs the pack and every wolf for `secs`. Returns the most wolves that were
## darting in at once.
func _sim(pack: Node, secs: float) -> int:
var most := 0
var t := 0.0
while t < secs:
pack.step(0.1)
var lunging := 0
for w in pack.alive():
w.set_physics_process(false)
w.step(0.1)
if w.state == "lunge":
lunging += 1
most = maxi(most, lunging)
t += 0.1
return most
func _nearest(pack: Node, p: Vector3) -> float:
var best := INF
for w in pack.alive():
best = minf(best, (w as Node3D).global_position.distance_to(p))
return best
func _hold(player: Node, id: String) -> void:
var inv: Inventory = player.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and s.id == id:
var tmp = inv.get_slot(0)
inv.set_slot(0, s)
inv.set_slot(i, tmp)
break
player.select(0)
func _toast_has(s: String) -> bool:
for t in _toasts:
if t.contains(s):
return true
return false
func _body(world: Node3D) -> void:
var player: CharacterBody3D = world.get_node("Player")
var sv: Node = player.survival
var inv: Inventory = player.inventory
var terrain: Node3D = world.get_node("Terrain")
var den: Node3D = world.den
var pack: Node = world.wolves
player.set_physics_process(false)
sv.set_process(false)
world.day_night.frozen = true
world.weather.force("clear")
world.creatures.enabled = false
for c in world.creatures.get_children():
c.free()
pack.enabled = false
print("the den")
check(den.found and pack != null, "this island has a wolf den")
var dp: Vector3 = den.position
check(terrain.sea_weight(dp.x, dp.z) < 0.05 and Vector2(dp.x, dp.z).length() > 50.0, "it's in the mountains, away from the sea and the spawn (%.0f m out)" % Vector2(dp.x, dp.z).length())
var mouth: Vector3 = den.mouth_pos()
check(terrain.inside_boundary(mouth.x, mouth.z, 6.0), "its mouth opens onto walkable ground")
check(Vector2(mouth.x, mouth.z).length() < Vector2(dp.x, dp.z).length(), "and faces into the valley")
var trees := 0
for uid in world.props.resource_nodes:
var n = world.props.resource_nodes[uid]
if is_instance_valid(n) and not n.depleted and n.global_position.distance_to(dp) < 4.5:
trees += 1
check(trees == 0, "no trees or boulders grow inside it")
check(world.build.roofed_at(den.slot_pos(2)), "the cave has a rock roof")
print("grace period")
for d in [1, 2]:
for h in [12.0, 22.0]:
_set_time(world, d, h)
_sim(pack, 0.5)
check(pack.mode == "none" and pack.alive_count() == 0, "day %d %02d:00: no wolves yet" % [d, int(h)])
_set_time(world, 3, 12.0)
_sim(pack, 3.0)
check(pack.mode == "den" and pack.alive_count() == 2, "day 3: two wolves have moved into the den")
var resting := 0
for w in pack.alive():
if w.state == "rest" and w.global_position.distance_to(den.slot_pos(w.slot)) < 1.5:
resting += 1
check(resting == 2, "lying in the cave by day")
# Somewhere open, well away from the den, to be hunted in.
var site: Vector3 = world.build.debug_find_site(Vector3(0, 0, 0), 5.0)
site.y = terrain.sample_height(site.x, site.z)
var stand := site + Vector3(0, 0.02, 0)
player.global_position = stand
check(stand.distance_to(dp) > 45.0, "(test spot is %.0f m from the den)" % stand.distance_to(dp))
_set_time(world, 3, 21.0)
_toasts.clear()
_sim(pack, 20.0)
check(_toast_has("howling"), "night 3: you hear them howl (%s)" % [_toasts])
check(pack.mode == "den" and _nearest(pack, dp) < 6.0 and sv.health == 100.0, "but they stay home")
print("the hunt")
_toasts.clear()
_set_time(world, 4, 20.0)
var most := _sim(pack, 45.0)
check(_toast_has("hunting tonight"), "night 4: a warning that they're hunting")
check(pack.mode == "hunt", "the pack is out")
check(_nearest(pack, stand) < 18.0, "and has found you (nearest %.0f m)" % _nearest(pack, stand))
var waited := 0.0
while sv.health == 100.0 and waited < 60.0:
most = maxi(most, _sim(pack, 1.0))
waited += 1.0
most = maxi(most, _sim(pack, 8.0))
check(sv.health < 100.0 and not sv.dead, "out in the open, they bite (health %.0f after %.0f s)" % [sv.health, waited])
check(most == 1, "one wolf darts in at a time (most at once: %d)" % most)
sv.health = 100.0
player.global_position = stand
print("fire")
var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), site + Vector3(1.5, 0, 0)))
fire.fuel = fire.MAX_FUEL
_sim(pack, 5.0)
var closest := INF
var t := 0.0
while t < 60.0:
_sim(pack, 1.0)
closest = minf(closest, _nearest(pack, stand))
t += 1.0
check(sv.health == 100.0, "beside a lit campfire they never close in")
check(closest > 7.0, "they circle out beyond the firelight (closest %.0f m)" % closest)
world.remove_piece(fire)
print("torch")
inv.add("torch", 1)
_hold(player, "torch")
pack.lunge_done(null)
check(pack._gap >= pack.ATTACK_GAP * 2.0 * 0.8 - 0.01, "a torch makes them hesitate (%.1f s between darts)" % pack._gap)
player.select(7)
inv.set_slot(7, null)
print("walls")
var hut_site: Vector3 = world.build.debug_find_site(site + Vector3(14, 0, 0), 3.0)
var hut: Dictionary = await world.build.debug_build_hut(hut_site, 0.0)
check(hut.placed == 20, "(built a hut: %d pieces)" % hut.placed)
var inside: Vector3 = hut.pieces[0].global_position + Vector3(1, 0.05, 1)
player.global_position = inside
sv.health = 100.0
_sim(pack, 60.0)
check(sv.health == 100.0, "behind closed walls they can't reach you")
var bed: Node = world.spawn_piece(DB.get_item("bed"), Transform3D(Basis(), inside + Vector3(0.0, -0.05, -0.2)))
var w0: Node = pack.alive()[0]
w0.state = "stalk"
w0.global_position = inside + Vector3(6, 0, 0)
check(world.sleep_block_reason(bed).contains("wolves"), "and you can't sleep with them prowling outside")
world.remove_piece(bed)
player.global_position = stand
print("fighting back")
inv.add("club", 1)
_hold(player, "club")
var a: Node = pack.alive()[0]
var b: Node = pack.alive()[1]
for w in pack.alive():
w._aggro = 0.0
a.global_position = stand + Vector3(1.2, 0, 0)
sv.stamina = 100.0
A.melee(player, a)
check(b._aggro > 0.0, "hit one and the whole pack turns on you")
var hits := 1
while not a.wounded and not a.is_dead() and hits < 10:
sv.stamina = 100.0
a.global_position = stand + Vector3(1.2, 0, 0)
A.melee(player, a)
hits += 1
check(a.wounded and a.state == "flee", "a badly hurt wolf runs (after %d blows, hp %.0f)" % [hits, a.hp])
while not a.is_dead() and hits < 20:
sv.stamina = 100.0
a.global_position = stand + Vector3(1.2, 0, 0)
A.melee(player, a)
hits += 1
check(a.is_dead(), "but you can finish it")
A.butcher(player, a)
check(inv.count("wolf_pelt") == 1, "a wolf gives a pelt")
print("a lone wolf")
b._aggro = 0.0
b.wounded = false
b.state = "stalk"
b.global_position = stand + Vector3(7, 0, 0)
b.global_position.y = terrain.sample_height(b.global_position.x, b.global_position.z)
sv.health = 100.0
var lone_most := _sim(pack, 3.0)
check(b.state == "flee" or b.global_position.distance_to(stand) > 9.0, "on its own it backs away (%s, %.0f m)" % [b.state, b.global_position.distance_to(stand)])
lone_most = maxi(lone_most, _sim(pack, 30.0))
check(lone_most == 0 and sv.health == 100.0, "and doesn't start a fight")
print("dawn")
player.global_position = stand
_set_time(world, 5, 7.0)
_sim(pack, 120.0)
var home := 0
for w in pack.alive():
if w.global_position.distance_to(den.slot_pos(w.slot)) < 2.0:
home += 1
check(pack.mode == "den" and home == pack.alive_count() and home > 0, "at dawn they go home (%d of %d)" % [home, pack.alive_count()])
check(pack.alive_count() == 2, "and the pack is back up to two")
_set_time(world, 7, 12.0)
_sim(pack, 1.0)
check(pack.alive_count() == 3, "day 7: the pack has grown to three")
check(pack.pack_size(10) == 4, "and it reaches four from day 9")
print("the den by day")
player.global_position = dp + (mouth - dp).normalized() * 12.0
player.global_position.y = terrain.sample_height(player.global_position.x, player.global_position.z)
_sim(pack, 2.0)
var watching := 0
for w in pack.alive():
if w.state == "alert":
watching += 1
check(watching > 0 and sv.health == 100.0, "come near the den and they get up and watch you (%d)" % watching)
player.global_position = mouth
_sim(pack, 8.0)
check(sv.health < 100.0, "come right up to it and they defend it (health %.0f)" % sv.health)
sv.health = 100.0
print("wolf cloak")
var bench: Node = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), stand + Vector3(-2.5, -0.02, 0)))
player.global_position = stand
inv.add("wolf_pelt", 2)
inv.add("hide", 2)
inv.add("fiber", 4)
check(A.craft(player, "wolf_cloak") and inv.count("wolf_cloak") == 1, "a wolf cloak from 3 pelts, 2 hide and 4 fiber")
world.remove_piece(bench)
check(float(DB.get_item("wolf_cloak").wear.warmth) > float(DB.get_item("hide_cloak").wear.warmth), "warmer than the hide cloak")
world.queue_free()
_finished = true