From f174221268218392683c12a358dc636d4f9753ba Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Wed, 4 Nov 2026 11:32:16 +0000 Subject: [PATCH] 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 --- data/items.json | 115 ++++++++++- data/recipes.json | 40 ++++ docs/ROADMAP.md | 4 +- docs/SETUP_ON_NEW_MACHINE.md | 4 + docs/SURVIVAL_DESIGN.md | 62 +++++- scripts/core/actions.gd | 31 +++ scripts/creatures/creature.gd | 52 ++++- scripts/creatures/creature_meshes.gd | 27 +++ scripts/creatures/den.gd | 174 +++++++++++++++++ scripts/creatures/wolf.gd | 163 ++++++++++++++++ scripts/creatures/wolf_pack.gd | 267 +++++++++++++++++++++++++ scripts/fishing/bobber.gd | 127 ++++++++++++ scripts/fishing/lake_fish.gd | 126 ++++++++++++ scripts/items/item_meshes.gd | 76 +++++++- scripts/player/hands.gd | 40 ++++ scripts/player/survival.gd | 66 ++++++- scripts/ui/hud.gd | 28 +++ scripts/ui/pause_menu.gd | 1 + scripts/world/props.gd | 9 +- scripts/world/world.gd | 134 +++++++++++++ tests/run_all.sh | 2 +- tests/test_fishing_diet.gd | 262 +++++++++++++++++++++++++ tests/test_wolves.gd | 280 +++++++++++++++++++++++++++ 23 files changed, 2069 insertions(+), 21 deletions(-) create mode 100644 scripts/creatures/den.gd create mode 100644 scripts/creatures/wolf.gd create mode 100644 scripts/creatures/wolf_pack.gd create mode 100644 scripts/fishing/bobber.gd create mode 100644 scripts/fishing/lake_fish.gd create mode 100644 tests/test_fishing_diet.gd create mode 100644 tests/test_wolves.gd diff --git a/data/items.json b/data/items.json index 6d7175f..920918a 100644 --- a/data/items.json +++ b/data/items.json @@ -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 + } + } } ] } diff --git a/data/recipes.json b/data/recipes.json index 573d965..e8904c5 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -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 + } } ] } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ef5bb59..994a3b3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 671d907..133a758 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -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 diff --git a/docs/SURVIVAL_DESIGN.md b/docs/SURVIVAL_DESIGN.md index 92c94d2..1ce61c1 100644 --- a/docs/SURVIVAL_DESIGN.md +++ b/docs/SURVIVAL_DESIGN.md @@ -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 | |---|---|---|---| diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 4f5dbd9..44bcf99 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -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) diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index bc16ab0..d062bfc 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -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: diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index 64f6799..eeda8b4 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -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} diff --git a/scripts/creatures/den.gd b/scripts/creatures/den.gd new file mode 100644 index 0000000..7dcfccf --- /dev/null +++ b/scripts/creatures/den.gd @@ -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 diff --git a/scripts/creatures/wolf.gd b/scripts/creatures/wolf.gd new file mode 100644 index 0000000..8c5d8fa --- /dev/null +++ b/scripts/creatures/wolf.gd @@ -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 diff --git a/scripts/creatures/wolf_pack.gd b/scripts/creatures/wolf_pack.gd new file mode 100644 index 0000000..33a122c --- /dev/null +++ b/scripts/creatures/wolf_pack.gd @@ -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 diff --git a/scripts/fishing/bobber.gd b/scripts/fishing/bobber.gd new file mode 100644 index 0000000..5210cfc --- /dev/null +++ b/scripts/fishing/bobber.gd @@ -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() diff --git a/scripts/fishing/lake_fish.gd b/scripts/fishing/lake_fish.gd new file mode 100644 index 0000000..33fd136 --- /dev/null +++ b/scripts/fishing/lake_fish.gd @@ -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) diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index c915d86..a9e661e 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -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) diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 8c604d6..22b2b3b 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -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: diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 97aecf6..21ec852 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -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() diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 1a3dfc7..258817a 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -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) diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index f2727cf..2d33caf 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -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)"], diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 1186d08..7dcab69 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -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 diff --git a/scripts/world/world.gd b/scripts/world/world.gd index cc6835c..277b072 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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. diff --git a/tests/run_all.sh b/tests/run_all.sh index 304ee09..bdf12a6 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -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, diff --git a/tests/test_fishing_diet.gd b/tests/test_fishing_diet.gd new file mode 100644 index 0000000..b4222c5 --- /dev/null +++ b/tests/test_fishing_diet.gd @@ -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 diff --git a/tests/test_wolves.gd b/tests/test_wolves.gd new file mode 100644 index 0000000..3910fe7 --- /dev/null +++ b/tests/test_wolves.gd @@ -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