From 9528f91e47e7a18795138193819ce463abd47363 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 09:23:31 +0000 Subject: [PATCH] P3 thieves and barrow: crows and foxes steal food left out; the barrow with its guards, coffin loot and key pieces; blunt club vs bone (protocol 10) --- data/items.json | 13 +- docs/PROGRESSION_DESIGN.md | 33 +- docs/ROADMAP.md | 1 + scripts/core/actions.gd | 25 +- scripts/creatures/bear_range.gd | 4 +- scripts/creatures/creature.gd | 24 +- scripts/creatures/creature_meshes.gd | 72 +++++ scripts/creatures/crow.gd | 115 +++++++ scripts/creatures/fox.gd | 95 ++++++ scripts/creatures/guard.gd | 110 +++++++ scripts/creatures/thieves.gd | 150 +++++++++ scripts/items/item_meshes.gd | 17 + scripts/net/creature_net.gd | 10 +- scripts/net/net.gd | 2 +- scripts/net/player_net.gd | 5 +- scripts/world/barrow.gd | 449 +++++++++++++++++++++++++++ scripts/world/world.gd | 32 +- tests/run_all.sh | 2 +- tests/test_barrow.gd | 312 +++++++++++++++++++ tests/test_thieves.gd | 245 +++++++++++++++ 20 files changed, 1695 insertions(+), 21 deletions(-) create mode 100644 scripts/creatures/crow.gd create mode 100644 scripts/creatures/fox.gd create mode 100644 scripts/creatures/guard.gd create mode 100644 scripts/creatures/thieves.gd create mode 100644 scripts/world/barrow.gd create mode 100644 tests/test_barrow.gd create mode 100644 tests/test_thieves.gd diff --git a/data/items.json b/data/items.json index 7b0571b..030bcc0 100644 --- a/data/items.json +++ b/data/items.json @@ -79,13 +79,14 @@ "name": "Wooden Club", "category": "weapon", "stack": 1, - "description": "Crude, heavy, effective. One blow fells a rabbit.", + "description": "Crude, heavy, effective. One blow fells a rabbit. Blunt: cracks bone (+50% on skeletons).", "mesh": "club", "weapon": { "damage": 12, "reach": 2.4, "stamina": 8, - "cooldown": 0.55 + "cooldown": 0.55, + "blunt": true }, "durability": 80, "repair": { @@ -969,6 +970,14 @@ 2.2 ] } + }, + { + "id": "barrow_key", + "name": "Barrow Key Piece", + "category": "resource", + "stack": 3, + "description": "A carved stone token from a barrow coffin. One of three: the standing stones on the rim are said to wake for them.", + "mesh": "barrow_key" } ] } diff --git a/docs/PROGRESSION_DESIGN.md b/docs/PROGRESSION_DESIGN.md index 9cd52c1..3da7f11 100644 --- a/docs/PROGRESSION_DESIGN.md +++ b/docs/PROGRESSION_DESIGN.md @@ -1,7 +1,7 @@ # Progression and challenges design -Status: **P1 (iron) and P2 (stone) built; P3 started (the alpha)**; the -rest of P3, and P4-P5, are proposals. +Status: **P1 (iron), P2 (stone) and P3 (adversaries) built**; P4-P5 are +proposals. *As built in P1:* - Iron nuggets (70, in the scree under the rim), clay (60, on the lake @@ -66,7 +66,34 @@ rest of P3, and P4-P5, are proposals. - Drops: bear hide, 2-3 fat (fire fuel, three times wood), 5-7 meat. Fur rug (bear hide, 2 fibre): in a sheltered room, Cozy: +4 warmth and Rested twice as fast. Network protocol 8. -- Not yet: crows and foxes, the barrow and its guards. + +*As built in P3 (crows and foxes):* +- Food left on the ground with nobody within 10 m for 20 s draws a thief + from 35 m away (at most 3 at once): a crow by day, a fox from 17:30 to + 06:30. Food in a chest is never touched by them. +- A crow flies in, lands and eats one every 3 s; anyone within 7 m and it + takes off. Shot down: 1-2 feathers. +- A fox slinks up and runs off with the whole stack; come within 10 m + while it's creeping in and it bolts empty-handed. Kill it while it's + carrying and it drops the food; let it get 45 m from everyone and it's + gone. Raw meat and a hide. + +*As built in P3 (the barrow):* +- One per island, on open ground (55 m+ from the spawn, 70 m+ from the + den; looser on lumpy islands): a grass mound you can walk over, a stone + passage into a 4 x 4 m chamber with a coffin, ringed by standing stones. +- Three guards (80 HP, hit for 20, move at 3.4 m/s: slower than you walk) + stand at their posts. They wake when someone is inside the stone ring at + night or any time after day 10, when the coffin is opened, or when one + is hit. Fire and torches don't bother them. Beyond 22 m from the barrow + they give up and walk back to their posts. +- The club is blunt: +50% on bone. Guards drop a little iron. +- The coffin: 1-3 iron bars, 3-6 nuggets and a barrow key piece (three + open the standing stones, P5). It fills again 5 days after it was + emptied, at dawn with nobody about, and the fallen guards are back. + Emptied coffin and fallen guards are saved. Network protocol 10. +- Not yet: iron scrap (bars and nuggets instead); the standing stones and + the Old Stag (P5). Today the island runs out of goals after a few days: once you have stone tools, a hut, a bow and a fire, the only threats are wolves at night, the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3c2864..1156a58 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,6 +41,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [x] Stone building: stonecutter, cut stone, stone floors, walls, pillars, stairs, windows, doorways, hearth; wooden stairs, windows, posts, palisade and gate *(P2)* - [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)* - [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)* +- [x] Crows and foxes steal food left out; the barrow, its guards, the coffin and key pieces *(P3, part 3)* - [ ] **Iron, stone building and new adversaries**: iron nuggets and ore, smelting and a forge, iron tools and durability, cut-stone building, an alpha wolf, a bear, a barrow with guards, spoilage and blizzards diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 6c30072..f8edd3f 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -305,6 +305,24 @@ func toggle_door(player: Node3D, door: Node3D) -> bool: door.set_open(not door.open) return true +## E on the barrow's coffin (barrow.gd): take what's in it. Opening it wakes +## the guards. What doesn't fit goes on the floor by you. +func loot_barrow(player: Node3D, barrow: Node) -> bool: + if barrow == null or not barrow.found or not _in_reach(player, barrow._coffin, 0.5): + return false + if _online(): + return _ask("barrow_loot", []) + var got: Dictionary = barrow.loot(player) + if got.is_empty(): + return false + for id in got: + var left: int = player.inventory.add(id, int(got[id])) + if left > 0: + barrow.world.drop_item(id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.3, 0))) + item_gained.emit(id, int(got[id])) + tell(player, "+%d %s" % [int(got[id]), ItemDB.item_name(id)]) + return true + ## E on a door an alpha broke: mend it (two wood), shut. func repair_door(player: Node3D, door: Node3D) -> bool: if not is_instance_valid(door) or not door.get("broken") or not _in_reach(player, door, 1.0): @@ -359,7 +377,8 @@ func melee_stats(player: Node) -> Dictionary: var w: Dictionary = held.get("weapon", {}) if float(w.get("damage", 0)) > 0.0: return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)), - "stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true} + "stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true, + "blunt": bool(w.get("blunt", false))} if held.has("tool"): return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true} return NO_WEAPON @@ -384,7 +403,9 @@ func melee(player: Node3D, target: Node3D) -> bool: if not player.survival.use_stamina(m.stamina): notify.emit("Too tired") return false - target.take_hit(m.damage, player.global_position) + # Blunt weapons (the club) crack bone: +50% on skeletons. + var bone := bool(target.def.get("bone", false)) if target.get("def") != null else false + target.take_hit(m.damage * (1.5 if bone and m.get("blunt", false) else 1.0), player.global_position) use_held(player) if target.is_dead(): notify.emit("Killed a %s" % target.display_name().to_lower()) diff --git a/scripts/creatures/bear_range.gd b/scripts/creatures/bear_range.gd index 22f8b5a..e4c6770 100644 --- a/scripts/creatures/bear_range.gd +++ b/scripts/creatures/bear_range.gd @@ -61,6 +61,8 @@ func _find_home() -> bool: continue if Vector2(c.x - spawn.x, c.z - spawn.z).length() < 60.0 or Vector2(c.x - den_at.x, c.z - den_at.z).length() < 60.0: continue + if world.barrow != null and world.barrow.found and Vector2(c.x - world.barrow.position.x, c.z - world.barrow.position.z).length() < 40.0: + continue if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0: continue var near := 0 @@ -202,7 +204,7 @@ func raid(b: Node3D, target: Node3D) -> int: what = "A bear has broken into your chest and eaten the food!" else: eaten = int(target.amount) - target.collected() + world.take_dropped(target) what = "A bear is eating the food you left out!" for p in world.players(): if p.global_position.distance_to(target.global_position) < 80.0: diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index fcb54c9..96c3255 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -46,6 +46,20 @@ const KINDS := { "notice": 0.0, "notice_loud": 0.0, "calm": 40.0, "damage": 16.0, "attack_range": 2.1, "attack_cd": 1.4, "door_damage": 22.0, "drops": {"raw_meat": [2, 3], "alpha_pelt": [1, 1], "wolf_fang": [1, 2]}}, + # Thieves (thieves.gd): crows by day and foxes at dusk and night come for + # food left lying on the ground. + "crow": {"name": "Crow", "hp": 6.0, "walk": 1.0, "run": 9.0, "turn": 8.0, + "notice": 0.0, "notice_loud": 0.0, "calm": 30.0, + "drops": {"feather": [1, 2]}}, + "fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0, + "notice": 0.0, "notice_loud": 0.0, "calm": 30.0, + "drops": {"raw_meat": [1, 1], "hide": [1, 1]}}, + # The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk + # away from one) but they hit hard; bone, so blunt weapons do +50%. + "guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0, + "notice": 0.0, "notice_loud": 0.0, "calm": 30.0, + "damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true, + "drops": {"iron_nugget": [1, 2]}}, # One bear per island, in the deep forest by the rim (bear.gd, bear_range.gd). # Slower than you sprinting, but it hits like a falling tree. "bear": {"name": "Bear", "hp": 220.0, "walk": 1.3, "run": 7.0, "turn": 3.5, @@ -118,10 +132,10 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: var eyes := MeshInstance3D.new() eyes.mesh = parts.eyes var em := StandardMaterial3D.new() - var red := k == "alpha" - em.albedo_color = Color(1.0, 0.3, 0.2) if red else Color(1.0, 0.85, 0.4) + var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0)}.get(k, Color(1.0, 0.75, 0.3)) + em.albedo_color = eye.lerp(Color.WHITE, 0.2) em.emission_enabled = true - em.emission = Color(1.0, 0.18, 0.1) if red else Color(1.0, 0.75, 0.3) + em.emission = eye em.emission_energy_multiplier = 2.5 eyes.material_override = em eyes.set_meta("keep_mat", true) @@ -141,9 +155,9 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01) cs.shape = cap cs.position = Vector3(0, parts.height * 0.5, 0) - if k != "rabbit": + if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright) # Long bodies: lay the capsule along the spine. - cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0}.get(k, 1.4) + cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3}.get(k, 1.4) cs.rotation.x = PI * 0.5 cs.position = Vector3(0, parts.height * 0.55, 0) add_child(cs) diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index 56033da..70cfbe6 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -20,6 +20,9 @@ static func build(kind: String) -> Dictionary: "wolf": d = _wolf() "alpha": d = _wolf(true) "bear": d = _bear() + "fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6) + "crow": d = _crow() + "guard": d = _guard() _: d = _boar() _cache[kind] = d return d @@ -178,3 +181,72 @@ static func _bear() -> Dictionary: return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.72, 0.13, 0.11, coat, dark, 262), "hips": [Vector3(-0.28, 0.72, -0.5), Vector3(0.28, 0.72, -0.5), Vector3(-0.28, 0.74, 0.6), Vector3(0.28, 0.74, 0.6)], "radius": 0.62, "height": 1.6} + +## Crow: a small black bird facing -Z. The two "legs" are its wings +## (crow.gd swings them out sideways and flaps them). +static func _crow() -> Dictionary: + var black := Color(0.08, 0.08, 0.1) + var sheen := Color(0.14, 0.15, 0.2) + var beak := Color(0.2, 0.18, 0.15) + var st := P._begin() + var r := _rng(291) + var n := _noise(291, 2.0) + P._blob(st, Vector3(0, 0.16, 0), Vector3(0.07, 0.07, 0.14), n, 0.1, black, r, 1) + P._blob(st, Vector3(0, 0.22, -0.13), Vector3(0.05, 0.05, 0.055), n, 0.1, sheen, r, 1) + P._cylinder(st, Vector3(0, 0.215, -0.17), Vector3(0, 0.2, -0.24), 0.016, 0.002, 4, beak, r) + P._cylinder(st, Vector3(0, 0.17, 0.12), Vector3(0, 0.15, 0.26), 0.03, 0.05, 4, black, r) + for s in [-1.0, 1.0]: + P._cylinder(st, Vector3(s * 0.025, 0.1, 0.0), Vector3(s * 0.03, 0.0, 0.02), 0.006, 0.005, 3, beak, r) + for s in [-1.0, 1.0]: # (dull eyes: no glowing "eyes" mesh for a crow) + P._blob(st, Vector3(s * 0.035, 0.235, -0.16), Vector3.ONE * 0.008, n, 0.0, Color(0.3, 0.25, 0.2), r, 0) + var wst := P._begin() + P._blob(wst, Vector3(0, -0.14, 0.02), Vector3(0.012, 0.15, 0.08), n, 0.05, sheen, r, 0) + return {"body": P._commit(st), "leg": P._commit(wst), + "hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)], + "radius": 0.12, "height": 0.3} + +## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old +## sword in its right hand and a round shield on its left arm. Two legs +## (creature.gd walks them like a pair of a quadruped's). +static func _guard() -> Dictionary: + var bone := Color(0.82, 0.79, 0.68) + var old := Color(0.62, 0.58, 0.48) + var rust := Color(0.42, 0.24, 0.14) + var dark := Color(0.06, 0.06, 0.07) + var wood := Color(0.32, 0.22, 0.14) + var st := P._begin() + var r := _rng(301) + var n := _noise(301, 2.2) + P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis + P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine + for i in range(4): # ribs + var y := 1.18 + 0.075 * i + P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r) + for s in [-1.0, 1.0]: + P._cylinder(st, Vector3(s * (0.15 - 0.01 * i), y, -0.02), Vector3(s * 0.11, y - 0.02, 0.07), 0.015, 0.015, 3, old, r) + P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders + P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull + P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw + P._cylinder(st, Vector3(0, 1.69, -0.01), Vector3(0, 1.82, -0.01), 0.14, 0.06, 7, rust, r, -1.0, rust) # helm + P._cylinder(st, Vector3(0, 1.66, -0.12), Vector3(0, 1.5, -0.14), 0.018, 0.012, 4, rust, r) # nose guard + for s in [-1.0, 1.0]: + P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets + # Right arm forward with the sword, left arm with the shield. + P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r) + P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r) + P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.29, 1.5, -0.95), 0.03, 0.006, 4, rust, r) # blade + P._cylinder(st, Vector3(0.2, 1.05, -0.38), Vector3(0.34, 1.1, -0.4), 0.02, 0.02, 4, rust, r) # crossguard + P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.3, 1.2, -0.05), 0.025, 0.022, 4, bone, r) + P._cylinder(st, Vector3(-0.3, 1.2, -0.05), Vector3(-0.28, 1.05, -0.25), 0.022, 0.02, 4, bone, r) + P._cylinder(st, Vector3(-0.33, 1.12, -0.3), Vector3(-0.31, 1.12, -0.36), 0.26, 0.26, 9, wood, r, -1.0, wood) # shield + P._cylinder(st, Vector3(-0.31, 1.12, -0.36), Vector3(-0.305, 1.12, -0.4), 0.06, 0.04, 6, rust, r, -1.0, rust) # boss + var est := P._begin() + for s in [-1.0, 1.0]: + P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 0.85, 1.0), r, 0) + var lst := P._begin() + P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r) + P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r) + P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0) + return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst), + "hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)], + "radius": 0.3, "height": 1.85} diff --git a/scripts/creatures/crow.gd b/scripts/creatures/crow.gd new file mode 100644 index 0000000..8c285dd --- /dev/null +++ b/scripts/creatures/crow.gd @@ -0,0 +1,115 @@ +extends "res://scripts/creatures/creature.gd" +## A crow (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd, by day, for +## food left lying on the ground. It flies in, lands on it and pecks it away +## a bit at a time, and takes off again if anyone comes within SCARE. Then +## it flies off over the trees and is gone. A closed chest keeps food safe. +## An arrow brings it down (feathers). + +const SCARE := 7.0 +const PECK_EVERY := 3.0 +const FLY_SPEED := 9.0 +const CRUISE := 7.0 ## height above the ground while flying in +const LEAVE_HEIGHT := 14.0 + +var thieves: Node ## thieves.gd +var food: Node3D = null +var _goal := Vector3.ZERO +var _peck_anim := 0.0 + +func display_name() -> String: + return "Crow" + +func interact_prompt(p: Node) -> String: + if is_dead(): + return "Crow [E] Pluck" + return {"peck": "Crow (pecking at your food!)", "fly": "Crow (circling your food)"}.get(state, "Crow") + +func _flying() -> bool: + return state in ["fly", "fly_off"] + +func _think(dt: float) -> void: + match state: + "fly": + if not thieves.still_there(food) or thieves.watched(food.global_position, SCARE): + _leave() + return + _goal = food.global_position + var ground: float = terrain.sample_height(global_position.x, global_position.z) + if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15: + state = "peck" + _state_t = PECK_EVERY + thieves.landed(self, food) + "peck": + if not thieves.still_there(food) or thieves.watched(global_position, SCARE): + _leave() + return + _state_t -= dt + if _state_t <= 0.0: + _state_t = PECK_EVERY + _peck_anim = 0.4 + if thieves.peck(self, food): # the last of it + _leave() + "fly_off": + if thieves.out_of_sight(global_position): + thieves.gone(self) + _: + _leave() + +func _leave() -> void: + state = "fly_off" + food = null + var p: Node3D = world.nearest_player(global_position) + var away := Vector3(1, 0, 0) + if p != null: + away = global_position - p.global_position + away.y = 0.0 + if away.length() < 0.1: + away = Vector3(1, 0, 0) + _goal = global_position + away.normalized() * 120.0 + +## Straight-line flight (no steering round trees: it flies over them). +func _move(dt: float) -> void: + if not _flying(): + speed = 0.0 + return + var to := _goal - global_position + to.y = 0.0 + var flat := to.length() + var spd := FLY_SPEED + if state == "fly" and flat < 6.0: + spd = maxf(flat * 1.5, 1.2) # glide in to land + var p := global_position + if flat > 0.02: + yaw = rotate_toward(yaw, atan2(-to.x, -to.z), 8.0 * dt) + p += to / flat * minf(spd * dt, flat) + var ground: float = terrain.sample_height(p.x, p.z) + var alt := LEAVE_HEIGHT + if state == "fly": + alt = CRUISE * clampf((flat - 0.3) / 8.0, 0.0, 1.0) + p.y = maxf(move_toward(global_position.y, maxf(ground, terrain.water_level) + alt, 5.0 * dt), ground) + global_position = p + rotation.y = yaw + speed = spd + +## Wings out and flapping in the air, folded on the ground; a dip of the +## head when it pecks. +func _animate(dt: float) -> void: + var flying := _flying() or (puppet and speed > 0.5) + _phase += dt * 16.0 + for l in _legs: + var s := -1.0 if l.position.x < 0.0 else 1.0 + l.rotation.z = s * ((PI * 0.5 + sin(_phase) * 0.7) if flying else 0.2) + _peck_anim = maxf(_peck_anim - dt, 0.0) + if is_dead(): + return + _visual.rotation.x = 0.5 * sin(_peck_anim / 0.4 * PI) if state == "peck" else 0.0 + +func _react(_from: Vector3) -> void: + _leave() + +## Brought down out of the air: it lands where it fell. +func _die(from: Vector3) -> void: + super._die(from) + var p := global_position + p.y = terrain.sample_height(p.x, p.z) + global_position = p diff --git a/scripts/creatures/fox.gd b/scripts/creatures/fox.gd new file mode 100644 index 0000000..bdd3bdb --- /dev/null +++ b/scripts/creatures/fox.gd @@ -0,0 +1,95 @@ +extends "res://scripts/creatures/creature.gd" +## A fox (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd at dusk and +## night for food left lying on the ground. It slinks up, grabs the whole +## stack in its jaws and runs off with it. Come within WARY while it's +## creeping in and it bolts empty-mouthed. Kill it while it's carrying and +## it drops what it took; let it get away and the food's gone. + +const WARY := 10.0 +const SNEAK_SPEED := 3.4 +const GIVE_UP := 30.0 ## seconds running with it before it's gone for good + +var thieves: Node +var food: Node3D = null +var carry: Dictionary = {} ## {id, count, extra} once it has the food +var _escape := Vector3.ZERO +var _goal_d := 0.0 + +func display_name() -> String: + return "Fox" + +func interact_prompt(p: Node) -> String: + if is_dead(): + return "Fox carcass [E] Butcher" + if not carry.is_empty(): + return "Fox (running off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower() + return "Fox (sneaking up on your food)" if state == "sneak" else "Fox" + +func _think(dt: float) -> void: + var pp: Vector3 = player.global_position + _state_t -= dt + match state: + "sneak": + if not thieves.still_there(food): + _bolt(pp) + return + if thieves.watched(global_position, WARY): + _bolt(pp) + return + var fp: Vector3 = food.global_position + _go_to(fp) + if Vector2(fp.x - global_position.x, fp.z - global_position.z).length() < 0.9: + carry = thieves.grab(self, food) + food = null + state = "carry" + _state_t = GIVE_UP + _escape = _away_point(pp, 60.0) + "carry", "flee": + _go_to(_escape) + if thieves.out_of_sight(global_position) or _state_t <= 0.0 or _goal_d < 1.5: + thieves.gone(self) + _: + _bolt(pp) + +func _bolt(from: Vector3) -> void: + state = "flee" if carry.is_empty() else "carry" + food = null + _state_t = GIVE_UP + _escape = _away_point(from, 60.0) + +## A point `d` away from `from`, through the fox, kept inside the valley. +func _away_point(from: Vector3, d: float) -> Vector3: + var away := global_position - from + away.y = 0.0 + if away.length() < 0.1: + away = Vector3(1, 0, 0) + away = away.normalized() + for turn in [0.0, 0.6, -0.6, 1.2, -1.2, PI]: + var p: Vector3 = global_position + away.rotated(Vector3.UP, turn) * d + if terrain.inside_boundary(p.x, p.z, 8.0) and terrain.sample_height(p.x, p.z) > terrain.water_level + 0.3: + return p + return global_position + away * d * 0.3 + +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() + +func _desired_speed() -> float: + match state: + "sneak": return 0.0 if _goal_d < 0.5 else SNEAK_SPEED + "carry", "flee": return def.run + return 0.0 + +func _react(from: Vector3) -> void: + _bolt(from) + +## Killed with the food in its jaws: it drops it. +func _die(from: Vector3) -> void: + super._die(from) + if not carry.is_empty() and not puppet: + var at := global_position + Vector3(0, 0.3, 0) + world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {})) + carry = {} diff --git a/scripts/creatures/guard.gd b/scripts/creatures/guard.gd new file mode 100644 index 0000000..f81d03f --- /dev/null +++ b/scripts/creatures/guard.gd @@ -0,0 +1,110 @@ +extends "res://scripts/creatures/creature.gd" +## A barrow guard (docs/PROGRESSION_DESIGN.md §4): a skeleton standing at its +## post in the barrow (barrow.gd) until something wakes it: someone inside +## the stone ring at night (or any time after day 10), someone opening the +## coffin, or a blow. Then it comes for you. It's slow, so you can walk away +## from it, but it hits hard and it isn't afraid of fire or torches. It +## won't follow you far from the barrow; it goes back to its post and stands +## there again. + +var barrow: Node ## barrow.gd +var post := Vector3.ZERO +var post_yaw := 0.0 +var _goal_d := 0.0 + +func display_name() -> String: + return def.name + +func interact_prompt(p: Node) -> String: + if is_dead(): + return "Bones [E] Search" + var s := "Barrow guard" + match state: + "rest": s = "Barrow guard (still... for now)" + "charge": s = "Barrow guard (coming for you!)" + "home": s = "Barrow guard (going back to its post)" + if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"): + s += " (you need a weapon: a club cracks bone)" + return s + +func _think(dt: float) -> void: + var pp: Vector3 = player.global_position + var d := global_position.distance_to(pp) + _state_t -= dt + match state: + "rest": + target_yaw = post_yaw + if barrow.wakes_for(player): + state = "charge" + _attack_cd = 0.8 + "charge": + if player.survival.dead or barrow.center().distance_to(pp) > barrow.LEASH or not barrow.alarmed: + state = "home" + return + var to := pp - global_position + steer(atan2(-to.x, -to.z)) + _goal_d = d + if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player): + _attack_cd = def.attack_cd + player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard") + "home": + if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH: + state = "charge" + return + var to := post - global_position + to.y = 0.0 + _goal_d = to.length() + if _goal_d < 0.4: + state = "rest" + else: + steer(atan2(-to.x, -to.z)) + _: + state = "home" + +func _desired_speed() -> float: + match state: + "charge": return 0.0 if _goal_d < def.attack_range * 0.8 else def.run + "home": return 0.0 if _goal_d < 0.4 else def.walk * 1.5 + return 0.0 + +## Nothing solid between its sword and you. +func _can_reach(p: Node3D) -> bool: + var from := global_position + Vector3(0, 1.2, 0) + var to: Vector3 = p.global_position + Vector3(0, 1.0, 0) + var q := PhysicsRayQueryParameters3D.create(from, to, 1) + q.exclude = [get_rid(), p.get_rid(), terrain.get_node("TerrainBody").get_rid()] + return get_world_3d().direct_space_state.intersect_ray(q).is_empty() + +## In the barrow, what's ahead is measured from the stone floor (the ground +## under it is lower, and a probe aimed at that would hit the floor slab). +func _blocked(dir: Vector3) -> bool: + var probe := global_position + dir * (1.4 + speed * 0.15) + var f: float = barrow.floor_y_at(probe) + if f == -INF and barrow.floor_y_at(global_position) == -INF: + return super._blocked(dir) + if not terrain.inside_boundary(probe.x, probe.z, 6.0): + return true + var h: float = f if f > -INF else terrain.sample_height(probe.x, probe.z) + if absf(h - global_position.y) > 1.1: + return true + var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.45, 0), Vector3(probe.x, h + 0.45, probe.z), 1) + q.exclude = [get_rid(), terrain.get_node("TerrainBody").get_rid()] + return not get_world_3d().direct_space_state.intersect_ray(q).is_empty() + +## Inside the barrow it walks on the stone floor, not the ground under it. +func _move(dt: float) -> void: + super._move(dt) + var f: float = barrow.floor_y_at(global_position) + if f > global_position.y: + global_position.y = f + +func _react(_from: Vector3) -> void: + barrow.alarm() + state = "charge" + _attack_cd = maxf(_attack_cd, 0.6) + +## Standing upright, a blow knocks it to pieces (no toppling sideways). +func _die(from: Vector3) -> void: + super._die(from) + _visual.rotation = Vector3(-PI * 0.5, 0, 0) + _visual.position.y = 0.15 diff --git a/scripts/creatures/thieves.gd b/scripts/creatures/thieves.gd new file mode 100644 index 0000000..09767f3 --- /dev/null +++ b/scripts/creatures/thieves.gd @@ -0,0 +1,150 @@ +extends Node3D +## Thieves (docs/PROGRESSION_DESIGN.md §4): crows by day and foxes at dusk +## and night, after food left lying on the ground. +## +## Food on the ground that nobody has been within UNWATCHED of for +## NOTICE_AFTER seconds draws a thief. It comes from COME_FROM away, out of +## sight of everyone. A crow pecks the stack away one at a time; a fox runs +## off with all of it. Either one gives up if you come near (crow.gd, +## fox.gd). Food in a chest is never touched (that's what chests are for); +## the bear (bear_range.gd) is another matter. +## +## On a server this runs for everyone (thieves are wildlife: creature_net.gd). + +const CrowScript = preload("res://scripts/creatures/crow.gd") +const FoxScript = preload("res://scripts/creatures/fox.gd") + +const CHECK_EVERY := 5.0 +const UNWATCHED := 10.0 +const NOTICE_AFTER := 20.0 +const MAX_THIEVES := 3 +const COME_FROM := 35.0 +const OUT_OF_SIGHT := 45.0 ## this far from everyone, a thief that's leaving is gone + +signal stolen(thief: Node, id: String, count: int) + +var world: Node3D +var enabled := true +var _acc := 0.0 +var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone +var _rng := RandomNumberGenerator.new() + +func setup(w: Node3D) -> void: + world = w + _rng.seed = w.terrain.noise_seed + 997 + +func _physics_process(delta: float) -> void: + if enabled: + step(delta) + +## Living thieves, coming or going. +func alive() -> Array: + return get_children().filter(func(c): return is_instance_valid(c) and c.has_method("is_dead") and not c.is_dead() and not c.is_queued_for_deletion()) + +static func is_food(id: String) -> bool: + return ItemDB.get_item(id).has("food") + +func night() -> bool: + var h: float = world.day_night.hours() + return h >= 17.5 or h < 6.5 + +## Someone (alive) within `r` of `pos`. +func watched(pos: Vector3, r: float) -> bool: + for p in world.players(): + if not p.survival.dead and p.global_position.distance_to(pos) < r: + return true + return false + +func out_of_sight(pos: Vector3) -> bool: + return not watched(pos, OUT_OF_SIGHT) + +func still_there(food: Node3D) -> bool: + return food != null and is_instance_valid(food) and not food.is_queued_for_deletion() and not food.taken and int(food.amount) > 0 + +func _targeted(food: Node3D) -> bool: + for t in alive(): + if t.food == food: + return true + return false + +func step(dt: float) -> void: + _acc += dt + if _acc < CHECK_EVERY: + return + var since := _acc + _acc = 0.0 + var seen := {} + for d in world.all_dropped(): + if not is_food(d.item_id): + continue + var k: int = d.get_instance_id() + seen[k] = true + if watched(d.global_position, UNWATCHED): + _unwatched.erase(k) + continue + _unwatched[k] = float(_unwatched.get(k, 0.0)) + since + if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES: + send(d) + for k in _unwatched.keys(): + if not seen.has(k): + _unwatched.erase(k) + +## Sends a thief after `food`: a fox at dusk and night, otherwise a crow. +func send(food: Node3D) -> Node3D: + var fox := night() + var fp: Vector3 = food.global_position + var from := Vector3.INF + for _i in range(12): + var a := _rng.randf() * TAU + var p := fp + Vector3(cos(a), 0, sin(a)) * COME_FROM + var ground: float = world.terrain.sample_height(p.x, p.z) + if world.terrain.inside_boundary(p.x, p.z, 6.0) and (not fox or ground > world.terrain.water_level + 0.3) \ + and not watched(p, 20.0): + from = p + break + if from == Vector3.INF: + return null + var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new() + t.setup("fox" if fox else "crow", world, from, _rng.randi()) + t.thieves = self + t.food = food + t.state = "sneak" if fox else "fly" + add_child(t) + if not fox: + t.global_position.y = world.terrain.sample_height(from.x, from.z) + t.CRUISE + var to := fp - from + t.yaw = atan2(-to.x, -to.z) + t.rotation.y = t.yaw + return t + +## Whoever left `food` there (anyone within 60 m) hears about it, once. +func _tell(food_pos: Vector3, text: String) -> void: + for p in world.players(): + if p.global_position.distance_to(food_pos) < 60.0: + Actions.tell(p, text) + +func landed(crow: Node, food: Node3D) -> void: + _tell(food.global_position, "A crow is pecking at your %s!" % ItemDB.item_name(food.item_id).to_lower()) + +## A crow takes one. True when that was the last of it. +func peck(crow: Node, food: Node3D) -> bool: + if not still_there(food): + return true + food.amount = int(food.amount) - 1 + stolen.emit(crow, food.item_id, 1) + if int(food.amount) <= 0: + world.take_dropped(food) + return true + return false + +## A fox takes the lot. Returns what it's carrying. +func grab(fox: Node, food: Node3D) -> Dictionary: + var c := {"id": String(food.item_id), "count": int(food.amount), "extra": food.extra.duplicate()} + _tell(food.global_position, "A fox has run off with your %s!" % ItemDB.item_name(c.id).to_lower()) + world.take_dropped(food) + stolen.emit(fox, c.id, c.count) + return c + +## A thief left with what it took (or empty-handed). +func gone(t: Node) -> void: + t.queue_free() diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 71bc52b..b0b57c8 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -115,6 +115,7 @@ static func build(key: String) -> ArrayMesh: "bear_hide": m = _pelt(BEAR_FUR) "bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1) "fur_rug": m = _rug() + "barrow_key": m = _barrow_key() "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -1002,3 +1003,19 @@ static func _rug() -> ArrayMesh: _box(st, Vector3(sx, 0.11, -0.8), Vector3(0.08, 0.05, 0.05), P._mul(BEAR_FUR, 0.8)) _box(st, Vector3(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR) return P._commit(st) + +# --- P3 the barrow -------------------------------------------------------------------- + +## A third of a carved stone disc, with a groove and a pale inlay. +static func _barrow_key() -> ArrayMesh: + var st := P._begin() + var r := _rng(311) + var segs := 5 + for i in range(segs): + var a0 := -0.55 + 1.1 * i / segs + var a1 := -0.55 + 1.1 * (i + 1) / segs + var c := Vector3(sin((a0 + a1) * 0.5), 0, cos((a0 + a1) * 0.5)) * 0.09 + var w := 2.0 * 0.09 * sin((a1 - a0) * 0.5) + 0.006 + _box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5)) + _box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7)) + return P._commit(st) diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd index b4762e8..d3e14ef 100644 --- a/scripts/net/creature_net.gd +++ b/scripts/net/creature_net.gd @@ -19,6 +19,8 @@ extends Node const Creature = preload("res://scripts/creatures/creature.gd") const BearScript = preload("res://scripts/creatures/bear.gd") +const CrowScript = preload("res://scripts/creatures/crow.gd") +const GuardScript = preload("res://scripts/creatures/guard.gd") # (falls apart in its own way) const Bobber = preload("res://scripts/fishing/bobber.gd") const Projectile = preload("res://scripts/creatures/projectile.gd") const WolfPack = preload("res://scripts/creatures/wolf_pack.gd") @@ -96,9 +98,9 @@ func _server_tick(w: Node3D, delta: float) -> void: const PER_PACKET := 40 const ENTRY := 23 -const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear"] +const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard"] const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash", - "warn", "raid", "eat"] + "warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry"] ## One creature in 23 bytes: id, kind, state, position, facing, speed, health. static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void: @@ -194,7 +196,9 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void: var got: Variant = puppets.get(cid) var c: Node3D = got if is_instance_valid(got) else null if c == null: - c = BearScript.new() if kind == "bear" else Creature.new() # (the bear rears up when it warns you) + # (Kinds with their own look: the bear rears up, a crow flaps.) + var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript}.get(kind, Creature) + c = script.new() c.puppet = true c.setup(kind, world, e[2], cid) c.set_meta("cid", cid) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 0d80467..16cdaad 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 8 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear (P3) +const PROTOCOL := 10 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 028686b..6d86255 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -324,6 +324,9 @@ func _run(p: Node3D, what: String, a: Array) -> void: if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \ and Actions.toggle_door(p, d): WorldNet.piece_state(d) + "barrow_loot": + if w.barrow != null: + Actions.loot_barrow(p, w.barrow) "door_repair": var d := WorldNet.piece(_i(a, 0)) if d != null and d.get("broken") != null and _allowed(Rules.use_problem(p.player_id, d)) \ @@ -426,7 +429,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: for m in w.get_tree().get_nodes_in_group("processor"): m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0) "debug_spawn": - if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear"]) and _v(a, 1).is_finite(): + if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard"]) and _v(a, 1).is_finite(): w.creatures.spawn(_s(a, 0), _v(a, 1)) "debug_time": if cheats: diff --git a/scripts/world/barrow.gd b/scripts/world/barrow.gd new file mode 100644 index 0000000..8019ad5 --- /dev/null +++ b/scripts/world/barrow.gd @@ -0,0 +1,449 @@ +extends Node3D +## The barrow (docs/PROGRESSION_DESIGN.md §4): an ancient burial mound in a +## ring of standing stones, out on open ground. Scenery, like the wolf den: +## built from the seed on every machine, so it's the same for everyone. +## +## A stone-lined passage leads under the mound into a chamber with a stone +## coffin at the back. Three skeletal guards (guard.gd) stand inside. They +## wake when someone's inside the stone ring at night (or at any time after +## day WAKE_DAY), when the coffin is opened, or when one of them is hit. +## The coffin holds iron and a barrow key piece (three open the standing +## stones on the rim, P5). It fills again RESTOCK_DAYS after it was emptied, +## and fallen guards are back at their posts then too. +## +## Guards and loot run on the server (or in single-player): start(). +## Saved with the world: when the coffin was emptied and which guards fell. +## +## Local space: +Z is the passage's mouth (facing the centre of the valley), +## the origin is the chamber floor. + +const IM = preload("res://scripts/items/item_meshes.gd") +const P = preload("res://scripts/world/proc_mesh.gd") +const GuardScript = preload("res://scripts/creatures/guard.gd") + +const MOUND_R := 6.5 +const MOUND_H := 3.6 +const RING_R := 10.0 ## the standing stones +const CLEAR_R := 11.5 +const LEASH := 22.0 ## guards don't follow you further than this from the centre +const WAKE_DAY := 10 ## after this, they wake by day too +const RESTOCK_DAYS := 5 +const PASSAGE_END := 6.4 ## the mouth, local z +const POSTS := [[Vector3(-1.4, 0, -0.4), 0.0], [Vector3(1.4, 0, -0.4), 0.0], [Vector3(0.0, 0, 1.2), 0.0]] +const LOOT := {"iron_bar": [1, 3], "iron_nugget": [3, 6], "barrow_key": [1, 1]} + +const STONE := Color(0.5, 0.5, 0.47) +const MOSS := Color(0.32, 0.38, 0.22) +const TURF := Color(0.3, 0.4, 0.18) +const EARTH := Color(0.36, 0.3, 0.2) + +var world: Node3D +var terrain: Node3D +var found := false +var yaw := 0.0 +var alarmed := false ## guards stirred (the coffin was opened, one was hit) +var loot_day := -1 ## the day it was emptied (-1: never) +var fallen: Array = [] ## post indices whose guard is dead +var guards: Array = [] +var running := false ## this machine runs the guards (server / single-player) +var _rng := RandomNumberGenerator.new() +var _mouth_drop := 0.0 ## how far the ground at the mouth is below the floor + +# --- placing it ----------------------------------------------------------------------- + +func place(w: Node3D) -> bool: + world = w + terrain = w.terrain + var site := _find_site() + if site == Vector3.INF: + push_warning("[barrow] no site for the barrow on this seed") + return false + found = true + position = site + yaw = atan2(-site.x, -site.z) # mouth toward the middle of the valley + rotation.y = yaw + var mouth: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, PASSAGE_END) + _mouth_drop = maxf(position.y - terrain.sample_height(mouth.x, mouth.z), 0.0) + _clear_area(w.props) + _build(w.props._rock_mat) + w.props.static_cuts.append([Transform3D(Basis(Vector3.UP, yaw), position), MOUND_R + 0.5, MOUND_R + 1.5]) + return true + +## Open, fairly flat ground away from the spawn, the den and the sea. Where +## nothing qualifies (a lumpy island), later passes accept rougher ground +## nearer the spawn and the den; the first pass is the original search, so +## islands that had a site keep it. +func _find_site() -> Vector3: + for pass_ in [[1.2, 55.0, 70.0], [2.0, 45.0, 50.0], [3.0, 40.0, 40.0]]: + var c: Vector3 = _search(float(pass_[0]), float(pass_[1]), float(pass_[2])) + if c != Vector3.INF: + return c + return Vector3.INF + +func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3: + _rng.seed = terrain.noise_seed + 555 + var spawn: Vector3 = world._spawn + var den_at: Vector3 = world.den.position if world.den != null and world.den.found else Vector3(1e9, 0, 1e9) + var best := Vector3.INF + var best_score := INF + for _i in range(600): + var a := _rng.randf() * TAU + var rr := _rng.randf_range(40.0, terrain.play_radius - 20.0) + var c := Vector2(cos(a), sin(a)) * rr + if not terrain.inside_boundary(c.x, c.y, CLEAR_R + 4.0) or terrain.sea_weight(c.x, c.y) > 0.02: + continue + if c.distance_to(Vector2(spawn.x, spawn.z)) < from_spawn or c.distance_to(Vector2(den_at.x, den_at.z)) < from_den: + continue + var hs: Array[float] = [] + for k in range(8): + var b := TAU * k / 8.0 + for rad in [MOUND_R * 0.5, PASSAGE_END + 0.5]: + hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad)) + var h0: float = terrain.sample_height(c.x, c.y) + hs.append(h0) + if hs.min() < terrain.water_level + 1.0: + continue + var spread: float = hs.max() - hs.min() + if spread > max_spread: + continue + var trees := 0 + for n in world.props.resource_nodes.values(): + if Vector2(n.global_position.x - c.x, n.global_position.z - c.y).length() < CLEAR_R: + trees += 1 + var score: float = spread * 4.0 + trees + if score < best_score: + best_score = score + # Floor level: the highest ground inside the chamber, so the + # terrain doesn't come up through the floor. + var top := h0 + for d in [Vector2(-2, -2), Vector2(2, -2), Vector2(-2, 2), Vector2(2, 2)]: + top = maxf(top, terrain.sample_height(c.x + d.x, c.y + d.y)) + best = Vector3(c.x, top + 0.03, c.y) + return best + +func _clear_area(props: Node3D) -> void: + 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 + if Vector2(n.global_position.x - position.x, n.global_position.z - position.z).length() < CLEAR_R: + if n.has_method("_set_instance"): + n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0))) + dict.erase(uid) + n.queue_free() + +# --- building it ----------------------------------------------------------------------- + +var _body: StaticBody3D +var _st: SurfaceTool +var _stone_rng := RandomNumberGenerator.new() + +## A dressed stone block: drawn and solid. +func _block(c: Vector3, s: Vector3, basis: Basis = Basis()) -> void: + var col := P._mul(STONE, _stone_rng.randf_range(0.8, 1.05)).lerp(MOSS, _stone_rng.randf() * 0.35) + IM._box(_st, c, s, col, basis) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = s + cs.shape = box + cs.transform = Transform3D(basis, c) + _body.add_child(cs) + +func _build(rock_mat: Material) -> void: + _stone_rng.seed = terrain.noise_seed + 556 + _body = StaticBody3D.new() + _body.name = "Stones" + _body.collision_layer = 1 | 4 # solid, and a roof for shelter checks + add_child(_body) + _st = P._begin() + # Chamber: 4 x 4 m inside, 2.4 m high, a slab roof. + _block(Vector3(0, -0.15, 0), Vector3(4.8, 0.36, 4.8)) # floor (top at 0.03) + _block(Vector3(0, 1.2, -2.2), Vector3(4.8, 2.4, 0.4)) # back + for s in [-1.0, 1.0]: + _block(Vector3(s * 2.2, 1.2, 0), Vector3(0.4, 2.4, 4.0)) # sides + _block(Vector3(s * 1.4, 1.2, 2.2), Vector3(1.6, 2.4, 0.4)) # front, either side of the passage + _block(Vector3(0, 2.6, 0), Vector3(4.8, 0.4, 4.8)) # roof + _block(Vector3(0, 2.15, 2.2), Vector3(1.2, 0.5, 0.4)) # over the passage's inner end + # Passage: 1.2 m wide, 1.95 m high, from the chamber to the mouth. Its + # floor slopes down to the ground at the mouth. + var plen := PASSAGE_END - 2.4 + var mid := 2.4 + plen * 0.5 + var tilt := atan2(_mouth_drop, plen) + _block(Vector3(0, -0.15 - _mouth_drop * 0.5, mid), Vector3(1.6, 0.36, sqrt(plen * plen + _mouth_drop * _mouth_drop)), Basis(Vector3.RIGHT, tilt)) + for s in [-1.0, 1.0]: + _block(Vector3(s * 0.8, 1.0 - _mouth_drop * 0.5, mid), Vector3(0.4, 2.0 + _mouth_drop, plen)) + _block(Vector3(0, 2.1, mid), Vector3(2.0, 0.3, plen)) + # The mouth: two tall jambs and a lintel. + for s in [-1.0, 1.0]: + _block(Vector3(s * 0.95, 1.1 - _mouth_drop * 0.5, PASSAGE_END + 0.1), Vector3(0.5, 2.6 + _mouth_drop, 0.5)) + _block(Vector3(0, 2.45, PASSAGE_END + 0.1), Vector3(2.6, 0.45, 0.6)) + # The coffin at the back: a stone chest with a lid. + _coffin = Coffin.new() + _coffin.barrow = self + _coffin.collision_layer = 1 + _coffin.position = Vector3(0, 0, -1.45) # (its origin is what reach is measured to) + add_child(_coffin) + var ccs := CollisionShape3D.new() + var cbox := BoxShape3D.new() + cbox.size = Vector3(1.9, 0.85, 0.8) + ccs.shape = cbox + ccs.position = Vector3(0, 0.45, 0) + _coffin.add_child(ccs) + IM._box(_st, Vector3(0, 0.4, -1.45), Vector3(1.8, 0.75, 0.75), P._mul(STONE, 0.85)) + IM._box(_st, Vector3(0, 0.82, -1.45), Vector3(1.95, 0.12, 0.85), STONE) + IM._box(_st, Vector3(0, 0.9, -1.45), Vector3(0.9, 0.05, 0.3), MOSS) + var mi := MeshInstance3D.new() + mi.mesh = P._commit(_st) + mi.material_override = ItemDB.item_material() + add_child(mi) + _build_mound() + _build_ring(rock_mat) + +## The grass mound over the chamber: a dome that meets the ground at its +## edge, open where the passage goes in. Its surface is solid (you can climb +## onto it); inside it you see the stone. +func _build_mound() -> void: + var st := P._begin() + var faces := PackedVector3Array() + var rings := 9 + var segs := 36 + var nz := FastNoiseLite.new() + nz.seed = terrain.noise_seed + 557 + var b := Basis(Vector3.UP, yaw) + var pt := func(i: int, j: int) -> Vector3: + var rr: float = MOUND_R * float(i) / rings + var a: float = TAU * float(j) / segs + var x: float = sin(a) * rr + var z: float = cos(a) * rr + var w: Vector3 = position + b * Vector3(x, 0, z) + var ground: float = terrain.sample_height(w.x, w.z) - position.y + var k: float = rr / MOUND_R + return Vector3(x, lerpf(MOUND_H, ground - 0.3, k * k) + nz.get_noise_2d(x * 3.0, z * 3.0) * 0.12 * (1.0 - k), z) + for i in range(rings): + for j in range(segs): + var q := [pt.call(i, j), pt.call(i + 1, j), pt.call(i + 1, j + 1), pt.call(i, j + 1)] + for tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]: + var c: Vector3 = (tri[0] + tri[1] + tri[2]) / 3.0 + if absf(c.x) < 1.05 and c.z > 2.0: + continue # the passage + if (tri[0] as Vector3).distance_to(tri[1]) < 0.001 or (tri[1] as Vector3).distance_to(tri[2]) < 0.001: + continue # (degenerate, at the top) + var col := TURF.lerp(EARTH, clampf(0.4 - c.y * 0.12, 0.0, 0.5)) + P._tri(st, tri[0], tri[1], tri[2], c - Vector3(0, -3, 0), P._mul(col, 0.9 + 0.2 * nz.get_noise_2d(c.x * 7.0, c.z * 7.0))) + var nrm: Vector3 = ((tri[1] as Vector3) - tri[0]).cross((tri[2] as Vector3) - tri[0]) + if nrm.dot(c - Vector3(0, -3, 0)) < 0.0: + faces.append_array(PackedVector3Array([tri[0], tri[2], tri[1]])) + else: + faces.append_array(PackedVector3Array([tri[0], tri[1], tri[2]])) + var mi := MeshInstance3D.new() + mi.name = "Mound" + mi.mesh = P._commit(st) + mi.material_override = ItemDB.item_material() + add_child(mi) + var shape := ConcavePolygonShape3D.new() + shape.set_faces(faces) + shape.backface_collision = true + var cs := CollisionShape3D.new() + cs.shape = shape + var body := StaticBody3D.new() + body.name = "MoundBody" + body.collision_layer = 1 | 4 + body.add_child(cs) + add_child(body) + +## Standing stones round the mound, with a gap in front of the mouth. +func _build_ring(rock_mat: Material) -> void: + var body := StaticBody3D.new() + body.name = "Ring" + body.collision_layer = 1 + add_child(body) + var n := 11 + for i in range(n): + var a := TAU * (i + 0.5) / n + if absf(wrapf(a, -PI, PI)) < 0.3: + continue # (the mouth faces +Z: a = 0) + var lp := Vector3(sin(a) * RING_R, 0, cos(a) * RING_R) + var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp + var radii := Vector3(0.55, 1.5 + 0.35 * sin(i * 2.3), 0.4) + var y: float = terrain.sample_height(w.x, w.z) - position.y + radii.y * 0.8 - 0.3 + var mesh := P.rock(terrain.noise_seed + 5200 + i, false) + var turn := Basis(Vector3.UP, a + 0.2 * sin(i * 1.7)) * Basis(Vector3.RIGHT, 0.05 * sin(i * 3.1)) + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.material_override = rock_mat + 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) + +# --- where things are -------------------------------------------------------------------- + +func center() -> Vector3: + return position + +func to_world(lp: Vector3) -> Vector3: + return position + Basis(Vector3.UP, yaw) * lp + +func to_local_pos(p: Vector3) -> Vector3: + return Basis(Vector3.UP, yaw).inverse() * (p - position) + +## Inside the chamber or passage. +func inside(p: Vector3) -> bool: + var l := to_local_pos(p) + return (absf(l.x) < 2.0 and absf(l.z) < 2.0 or absf(l.x) < 0.6 and l.z > 1.9 and l.z < PASSAGE_END + 0.3) and l.y > -1.0 and l.y < 2.6 + +## The stone floor's height at `p` (world), or -INF outside. +func floor_y_at(p: Vector3) -> float: + var l := to_local_pos(p) + if absf(l.x) < 2.0 and absf(l.z) < 2.0: + return position.y + 0.03 + if absf(l.x) < 0.6 and l.z >= 2.0 and l.z < PASSAGE_END: + return position.y + 0.03 - _mouth_drop * (l.z - 2.4) / (PASSAGE_END - 2.4) * float(l.z > 2.4) + return -INF + +func mouth() -> Vector3: + var m := to_world(Vector3(0, 0, PASSAGE_END + 1.2)) + m.y = terrain.sample_height(m.x, m.z) + return m + +# --- guards --------------------------------------------------------------------------- + +func is_night() -> bool: + var h: float = world.day_night.hours() + return h >= 19.5 or h < 5.5 + +## Whether being where `p` is wakes the guards right now. +func wakes_for(p: Node3D) -> bool: + if alarmed: + return true + if p.survival.dead: + return false + var near := Vector2(p.global_position.x - position.x, p.global_position.z - position.z).length() < RING_R + if near and (is_night() or world.day_night.day > WAKE_DAY): + alarmed = true + return alarmed + +func alarm() -> void: + alarmed = true + +func guards_alive() -> Array: + return guards.filter(func(g): return is_instance_valid(g) and not g.is_dead()) + +## Starts the guards (on the machine that runs creatures). +func start() -> void: + if not found: + return + running = true + _spawn_guards() + +func _spawn_guards() -> void: + for g in guards: + if is_instance_valid(g) and not g.is_dead(): + g.queue_free() + guards.clear() + for i in range(POSTS.size()): + if fallen.has(i): + continue + var g: AnimatableBody3D = GuardScript.new() + var post: Vector3 = to_world(POSTS[i][0]) + g.setup("guard", world, post, terrain.noise_seed + 600 + i) + g.barrow = self + g.post = Vector3(post.x, position.y + 0.03, post.z) + # Facing the mouth (+Z local); a creature's forward is -Z, so turn by PI. + g.post_yaw = yaw + PI + float(POSTS[i][1]) + g.state = "rest" + g.yaw = g.post_yaw + g.rotation.y = g.yaw + g.set_meta("post", i) + g.died.connect(_on_guard_died) + add_child(g) + g.global_position = g.post + guards.append(g) + +func _on_guard_died(g: Node) -> void: + var i := int(g.get_meta("post", -1)) + if i >= 0 and not fallen.has(i): + fallen.append(i) + +func _physics_process(_delta: float) -> void: + if running: + step() + +func step() -> void: + # Everyone's gone: the guards settle (they walk back to their posts). + if alarmed: + var anyone := false + for p in world.players(): + if not p.survival.dead and p.global_position.distance_to(position) < LEASH: + anyone = true + if not anyone: + alarmed = false + # The coffin fills again (and the fallen are back) a few days after it + # was emptied, at dawn, with nobody about. + if loot_day >= 0 and world.day_night.day >= loot_day + RESTOCK_DAYS: + var h: float = world.day_night.hours() + if h >= 6.0 and h < 10.0: + for p in world.players(): + if p.global_position.distance_to(position) < 30.0: + return + loot_day = -1 + fallen.clear() + alarmed = false + _spawn_guards() + +# --- the coffin --------------------------------------------------------------------------- + +var _coffin: StaticBody3D + +func stocked() -> bool: + return loot_day < 0 + +func days_to_restock() -> int: + return maxi(loot_day + RESTOCK_DAYS - int(world.day_night.day), 1) + +## Opens the coffin for `player` (Actions.loot_barrow). Wakes the guards. +## Returns what was taken ({} if it was empty). +func loot(player: Node3D) -> Dictionary: + alarm() + if not stocked(): + Actions.tell(player, "The coffin is empty. Come back in %d days." % days_to_restock()) + return {} + var r := RandomNumberGenerator.new() + r.seed = terrain.noise_seed * 31 + int(world.day_night.day) + var got := {} + for id in LOOT: + got[id] = r.randi_range(int(LOOT[id][0]), int(LOOT[id][1])) + loot_day = int(world.day_night.day) + Actions.tell(player, "You lift the coffin lid... and something stirs behind you!") + return got + +func coffin_prompt() -> String: + return "Stone coffin [E] Open" + +func save_data() -> Dictionary: + return {"loot_day": loot_day, "fallen": fallen.duplicate()} if found else {} + +func load_data(d: Dictionary) -> void: + if not found or d.is_empty(): + return + loot_day = int(d.get("loot_day", -1)) + fallen = [] + for i in d.get("fallen", []): + fallen.append(int(i)) + if running: + _spawn_guards() + +class Coffin extends StaticBody3D: + var barrow: Node + func interact_prompt(_p: Node) -> String: + return barrow.coffin_prompt() + func interact(p: Node) -> void: + Actions.loot_barrow(p, barrow) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index f69a171..e0c05d1 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -64,6 +64,8 @@ var den: Node3D ## den.gd: the wolves' cave at the foot of the r 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 bears: Node3D ## bear_range.gd: the island's bear (null online as a client) +var thieves: Node3D ## thieves.gd: crows and foxes after food left out +var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run) var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does) var net_sync: Node ## net_sync.gd when playing on a server, else null var has_bed_spawn := false @@ -340,6 +342,13 @@ func _on_terrain_ready() -> void: if den.place(self, props._rock_mat): props.static_cuts.append([den.global_transform, 2.2, 3.4]) _update_grass_cuts() + # The barrow: scenery too (P3), out on open ground. Its guards only run + # where creatures do (_setup_bear). + barrow = preload("res://scripts/world/barrow.gd").new() + barrow.name = "Barrow" + add_child(barrow) + if barrow.place(self): + _update_grass_cuts() if DisplayServer.get_name() != "headless": var fish := preload("res://scripts/fishing/lake_fish.gd").new() fish.name = "LakeFish" @@ -392,9 +401,17 @@ func _on_terrain_ready() -> void: _setup_bear() _maybe_capture() -## The island's bear and its patch (bear_range.gd). The server runs it for -## everyone; players' games see it through creature_net.gd. +## The island's bear and its patch (bear_range.gd), and the crows and foxes +## after food left out (thieves.gd). The server runs them for everyone; +## players' games see them through creature_net.gd. func _setup_bear() -> void: + thieves = preload("res://scripts/creatures/thieves.gd").new() + thieves.name = "Thieves" + add_child(thieves) + thieves.setup(self) + thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args() + if barrow != null and barrow.found and thieves.enabled: + barrow.start() bears = preload("res://scripts/creatures/bear_range.gd").new() bears.name = "Bear" add_child(bears) @@ -526,6 +543,7 @@ func collect_save() -> Dictionary: "pickups_taken": taken, "pieces": pieces, "bear": bears.save_data() if bears != null else _saved_bear, + "barrow": barrow.save_data() if barrow != null else {}, } func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary: @@ -538,6 +556,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop "pickups_taken": taken, "pieces": pieces, "bear": bears.save_data() if bears != null else _saved_bear, + "barrow": barrow.save_data() if barrow != null else {}, } ## The live pieces (placed and not being removed). @@ -664,6 +683,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: props.pickups[uid].collected() # Regrowth timers (older saves have none: everything gone starts a fresh timer). props.restore_regrow(s.get("regrow", {})) + if barrow != null: + barrow.load_data(s.get("barrow", {})) # The bear (bear_range.gd): applied when it's set up, after loading. _saved_bear = s.get("bear", {}) if bears != null: @@ -816,6 +837,13 @@ func drop_item(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) return null return _make_dropped(id, count, xf, extra) +## Something other than a player took a dropped item (the bear ate it, a fox +## ran off with it). On a server, everyone's game is told it's gone. +func take_dropped(pk: Node3D) -> void: + if server_mode and pk.has_meta("nid"): + WorldNet.taken("#%d" % int(pk.get_meta("nid"))) + pk.collected() + func _make_dropped(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D: var pk: Area3D = preload("res://scripts/world/pickup.gd").new() pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3) diff --git a/tests/run_all.sh b/tests/run_all.sh index 1d73c16..bf116b3 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host 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_alpha test_bear test_thieves test_barrow test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host 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_barrow.gd b/tests/test_barrow.gd new file mode 100644 index 0000000..2734c76 --- /dev/null +++ b/tests/test_barrow.gd @@ -0,0 +1,312 @@ +extends SceneTree +## P3 the barrow (docs/PROGRESSION_DESIGN.md §4): the burial mound in its +## stone ring, its passage and chamber, the guards that wake at night (or +## after day 10, or when the coffin's opened), clubs cracking bone, the loot +## and its key piece, restocking, and saving. +## Run: godot --headless --path . --script res://tests/test_barrow.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var _toasts: Array[String] = [] +var world: Node3D +var player: CharacterBody3D +var bw: Node + +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) + S.current_id = S.create_world("Barrow", 1337) + await _load() + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.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 _load() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + if world.wolves != null: + world.wolves.enabled = false + world.bears.enabled = false + world.thieves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + bw = world.barrow + bw.running = false # (the test steps it) + for g in bw.guards: + g.set_physics_process(false) + +func _reload() -> void: + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + +func _set_time(day: int, hours: float) -> void: + world.day_night.day = day + world.day_night.time = hours / 24.0 + world.day_night.apply(true) + +func _sim(secs: float, until: Callable = Callable()) -> float: + var t := 0.0 + while t < secs: + bw.step() + for g in bw.guards: + if is_instance_valid(g): + g.set_physics_process(false) + g.step(0.1) + t += 0.1 + if until.is_valid() and until.call(): + break + if int(t * 10) % 10 == 0: + await physics_frame + return t + +func _awake() -> int: + return bw.guards_alive().filter(func(g): return g.state == "charge").size() + +func _toast_has(s: String) -> bool: + for t in _toasts: + if t.contains(s): + return true + return false + +## Stand somewhere in barrow-local space (feet on the floor or the ground). +func _stand(lp: Vector3) -> void: + var w: Vector3 = bw.to_world(lp) + var f: float = bw.floor_y_at(w) + w.y = f if f > -INF else world.terrain.sample_height(w.x, w.z) + player.global_position = w + Vector3(0, 0.05, 0) + player.velocity = Vector3.ZERO + +func _reset_guards() -> void: + bw.alarmed = false + for g in bw.guards_alive(): + g.global_position = g.post + g.state = "rest" + g._aggro = 0.0 + +func _body() -> void: + await _scenery() + await _walk_in() + await _by_day() + await _loot() + await _at_night() + await _late() + await _bone() + await _restock() + await _saving() + _net() + _finished = true + +# --- the mound ---------------------------------------------------------------------- + +func _scenery() -> void: + print("the barrow") + check(bw.found, "this island has a barrow") + var c: Vector3 = bw.center() + var spawn: Vector3 = world._spawn + check(Vector2(c.x - spawn.x, c.z - spawn.z).length() > 55.0 and c.distance_to(world.den.position) > 70.0, + "out in the valley, away from the spawn (%.0f m) and the den (%.0f m)" % [Vector2(c.x - spawn.x, c.z - spawn.z).length(), c.distance_to(world.den.position)]) + check(Vector2(c.x - world.bears.home.x, c.z - world.bears.home.z).length() > 40.0, "and from the bear's home") + var trees := 0 + for n in world.props.resource_nodes.values(): + if Vector2(n.global_position.x - c.x, n.global_position.z - c.z).length() < bw.CLEAR_R: + trees += 1 + check(trees == 0, "nothing grows inside the stone ring") + var again: Node3D = load("res://scripts/world/barrow.gd").new() + again.world = world + again.terrain = world.terrain + check(again._find_site().distance_to(c) < 0.01, "the same place every time for this seed") + again.free() + var stones := 0 + for ch in bw.get_node("Ring").get_children(): + stones += 1 + check(stones >= 9, "ringed by %d standing stones" % stones) + check(bw.guards.size() == 3 and bw.guards.all(func(g): return g.state == "rest"), "three guards stand at their posts") + var inside: Vector3 = bw.to_world(Vector3(0, 0.05, 0.5)) + check(world.build.roofed_at(inside), "the chamber has a roof (dry inside)") + var top := PhysicsRayQueryParameters3D.create(c + Vector3(0, 10, 0), c + Vector3(0, -2, 0), 1) + var hit := world.get_world_3d().direct_space_state.intersect_ray(top) + check(not hit.is_empty() and hit.position.y > c.y + 2.8, "you can stand on top of the mound (%.1f m up)" % (hit.position.y - c.y if not hit.is_empty() else 0.0)) + +func _walk_in() -> void: + print("going in") + _set_time(3, 12.0) + var m: Vector3 = bw.mouth() + var along := PhysicsRayQueryParameters3D.create(bw.to_world(Vector3(0, 1.0, bw.PASSAGE_END + 1.0)), bw.to_world(Vector3(0, 1.0, 0.0)), 1) + along.exclude = [player.get_rid()] + check(world.get_world_3d().direct_space_state.intersect_ray(along).is_empty(), "the passage is open from the mouth to the chamber") + player.global_position = m + Vector3(0, 0.1, 0) + var to: Vector3 = bw.center() - m + player.rotation = Vector3(0, atan2(-to.x, -to.z), 0) + player.velocity = Vector3.ZERO + player.set_physics_process(true) + Input.action_press("move_forward") + var got_in := false + for _i in range(160): + await physics_frame + var l: Vector3 = bw.to_local_pos(player.global_position) + if l.z < 0.6 and absf(l.x) < 2.0: + got_in = true + break + Input.action_release("move_forward") + for _i in range(5): + await physics_frame + player.set_physics_process(false) + var l2: Vector3 = bw.to_local_pos(player.global_position) + check(got_in, "you walk down the passage into the chamber (at %.1f, %.1f, %.1f local)" % [l2.x, l2.y, l2.z]) + check(bw.inside(player.global_position), "(inside the barrow)") + +func _by_day() -> void: + print("by day") + _stand(Vector3(0, 0, 0.3)) + await _sim(8.0) + check(_awake() == 0, "day 3, noon, in the chamber: the guards don't stir") + check(bw.guards[0].interact_prompt(player).contains("still"), "\"%s\"" % bw.guards[0].interact_prompt(player)) + +func _loot() -> void: + print("the coffin") + player.inventory.load_data(Inventory.new(40).to_data()) + _stand(Vector3(0, 0, -0.5)) + _toasts.clear() + check(A.loot_barrow(player, bw), "you open the coffin") + check(player.inventory.count("barrow_key") == 1, "a barrow key piece") + check(player.inventory.count("iron_bar") >= 1 and player.inventory.count("iron_nugget") >= 3, + "and iron (%d bars, %d nuggets)" % [player.inventory.count("iron_bar"), player.inventory.count("iron_nugget")]) + check(_toast_has("something stirs"), "and something stirs (%s)" % [_toasts]) + await _sim(0.5) + check(_awake() == 3, "all three guards wake, even by day") + check(not A.loot_barrow(player, bw) and _toast_has("empty") and player.inventory.count("barrow_key") == 1, "open it again: empty") + print("getting away") + var hp0: float = player.survival.health + await _sim(6.0, func(): return player.survival.health < hp0) + check(player.survival.health <= hp0 - 19.0, "stay and they hit hard (%.0f)" % (hp0 - player.survival.health)) + player.survival.health = 100.0 + var g: Node3D = bw.guards[0] + check(float(g.def.run) < 5.0, "but they're slower than you walk (%.1f m/s)" % float(g.def.run)) + _stand(Vector3(0, 0, 30)) + await _sim(15.0) + check(_awake() == 0 and bw.guards_alive().all(func(gg): return gg.state in ["home", "rest"]), "past the stone ring and the leash they give up") + await _sim(25.0, func(): return bw.guards_alive().all(func(gg): return gg.state == "rest")) + check(bw.guards_alive().all(func(gg): return gg.state == "rest" and gg.global_position.distance_to(gg.post) < 0.6), "and walk back to their posts") + +func _at_night() -> void: + print("at night") + _reset_guards() + _set_time(4, 22.0) + _stand(Vector3(0, 0, 14)) + await _sim(3.0) + check(_awake() == 0, "outside the ring at night, they stay put") + _stand(Vector3(0, 0, 8)) + await _sim(1.0) + check(_awake() == 3, "step inside the ring at night and they come") + player.inventory.set_slot(0, {"id": "torch", "count": 1}) + player.select(0) + await _sim(2.0) + check(_awake() == 3, "a torch doesn't frighten them") + _stand(Vector3(0, 0, 40)) + await _sim(30.0, func(): return bw.guards_alive().all(func(gg): return gg.state == "rest")) + +func _late() -> void: + print("after day 10") + _reset_guards() + _set_time(11, 12.0) + _stand(Vector3(0, 0, 8)) + await _sim(1.0) + check(_awake() == 3, "day 11, noon: they wake by day too") + _stand(Vector3(0, 0, 40)) + await _sim(30.0, func(): return bw.guards_alive().all(func(gg): return gg.state == "rest")) + _reset_guards() + +func _bone() -> void: + print("bone") + _set_time(3, 12.0) + var g: Node3D = bw.guards[2] + _stand(bw.to_local_pos(g.global_position) + Vector3(0, 0, 1.6)) + player.inventory.set_slot(0, {"id": "club", "count": 1}) + player.select(0) + player.survival.stamina = 100.0 + var hp0: float = g.hp + check(A.melee(player, g), "a club...") + var club: float = hp0 - g.hp + player.inventory.set_slot(0, {"id": "iron_axe", "count": 1}) + player.select(0) + player.survival.stamina = 100.0 + hp0 = g.hp + A.melee(player, g) + var axe: float = hp0 - g.hp + check(absf(club - 18.0) < 0.01 and absf(axe - 14.0) < 0.01, "cracks bone: 18 a blow (12 +50%%), an iron axe 14 (%.0f, %.0f)" % [club, axe]) + check(bw.alarmed and g.state == "charge", "hit one and they all wake") + for gg in bw.guards_alive(): + gg.take_hit(500.0, player.global_position) + check(bw.guards_alive().is_empty() and bw.fallen.size() == 3, "all three fall") + var drops: Dictionary = g.butcher_drops() + check(int(drops.get("iron_nugget", 0)) >= 1, "their bones hold a little iron (%s)" % [drops]) + +func _restock() -> void: + print("restocking") + var looted: int = bw.loot_day + check(looted == 3, "(emptied on day 3)") + _set_time(looted + 4, 7.0) + _stand(Vector3(0, 0, 60)) + await _sim(0.3) + check(not bw.stocked() and bw.guards_alive().is_empty(), "4 days on: still empty, no guards") + _set_time(looted + 5, 7.0) + _stand(Vector3(0, 0, 12)) + await _sim(0.3) + check(not bw.stocked(), "5 days on, but you're right there: not yet") + _stand(Vector3(0, 0, 60)) + await _sim(0.3) + check(bw.stocked() and bw.guards_alive().size() == 3, "once you're away: the coffin's full and the guards are back") + +func _saving() -> void: + print("saving") + _set_time(9, 12.0) + _stand(Vector3(0, 0, -0.5)) + A.loot_barrow(player, bw) + bw.guards[1].take_hit(500.0, player.global_position) + await _reload() + check(not bw.stocked() and bw.loot_day == 9, "the coffin is still empty after a reload") + check(bw.guards.size() == 2 and bw.guards.all(func(g): return int(g.get_meta("post")) != 1), "and the fallen guard is still gone (%d standing)" % bw.guards.size()) + check(player.inventory.count("barrow_key") == 2, "two key pieces now (%d)" % player.inventory.count("barrow_key")) + +func _net() -> void: + print("online") + var CN: Node = root.get_node("CreatureNet") + var g: Node3D = bw.guards[0] + g.set_meta("cid", 7) + g.state = "charge" + var buf := PackedByteArray() + buf.resize(CN.ENTRY) + CN._encode(buf, 0, g) + var e: Array = CN._decode(buf, 0) + check(e[1] == "guard" and e[4] == "charge", "a guard goes over the network (%s, %s)" % [e[1], e[4]]) diff --git a/tests/test_thieves.gd b/tests/test_thieves.gd new file mode 100644 index 0000000..98f9f5d --- /dev/null +++ b/tests/test_thieves.gd @@ -0,0 +1,245 @@ +extends SceneTree +## P3 thieves (docs/PROGRESSION_DESIGN.md §4): crows by day and foxes at +## night come for food left on the ground. A crow pecks a stack away and +## takes off if you come near; a fox runs off with the lot, and drops it if +## you kill it. Food you're standing by, and food in a chest, is safe. +## Run: godot --headless --path . --script res://tests/test_thieves.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var _toasts: Array[String] = [] +var world: Node3D +var player: CharacterBody3D +var th: Node +var site: Vector3 + +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) + S.current_id = S.create_world("Thieves", 1337) + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + if world.wolves != null: + world.wolves.enabled = false + world.bears.enabled = false + if world.bears.bear != null: + world.bears.bear.queue_free() # (it would eat the test food) + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + th = world.thieves + th.enabled = false + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.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(hours: float) -> void: + world.day_night.day = 3 + world.day_night.time = hours / 24.0 + world.day_night.apply(true) + +## Runs the thieves for up to `secs`, stopping once `until` is true. +func _sim(secs: float, until: Callable = Callable()) -> float: + var t := 0.0 + while t < secs: + th.step(0.1) + for c in th.get_children(): + if is_instance_valid(c) and not c.is_queued_for_deletion(): + c.set_physics_process(false) + c.step(0.1) + t += 0.1 + if until.is_valid() and until.call(): + break + if int(t * 10) % 10 == 0: + await physics_frame + return t + +func _ground(p: Vector3) -> Vector3: + return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z) + +func _drop(id: String, n: int, at: Vector3) -> Node3D: + return world.drop_item(id, n, Transform3D(Basis(), _ground(at) + Vector3(0, 0.15, 0))) + +func _toast_has(s: String) -> bool: + for t in _toasts: + if t.contains(s): + return true + return false + +func _thief(kind: String) -> Node3D: + for c in th.alive(): + if c.kind == kind: + return c + return null + +func _clear() -> void: + for c in th.get_children(): + c.queue_free() + for d in world.all_dropped(): + d.queue_free() + await physics_frame + +func _body() -> void: + site = world.build.debug_find_site(world._spawn + player.global_basis.z * 30.0, 6.0) + await _crows() + await _scared() + await _safe() + await _foxes() + await _caught() + _net() + _finished = true + +func _crows() -> void: + print("crows") + _set_time(12.0) + player.global_position = _ground(site + Vector3(25, 0, 0)) + var berries := _drop("berries", 4, site) + await _sim(15.0) + check(th.alive().is_empty(), "food just put down draws nothing yet") + await _sim(10.0, func(): return not th.alive().is_empty()) + var crow := _thief("crow") + check(crow != null, "left alone 20 s by day, it draws a crow") + if crow == null: + return + check(crow.global_position.distance_to(berries.global_position) > 30.0 and crow.global_position.y > world.terrain.sample_height(crow.global_position.x, crow.global_position.z) + 4.0, + "flying in from %.0f m away" % crow.global_position.distance_to(berries.global_position)) + _toasts.clear() + var t: float = await _sim(20.0, func(): return crow.state == "peck") + check(crow.state == "peck" and crow.global_position.distance_to(berries.global_position) < 0.6, "it lands on the berries (%.0f s)" % t) + check(_toast_has("crow is pecking"), "and you hear it (%s)" % [_toasts]) + await _sim(6.5) + check(int(berries.amount) == 2, "it eats them one every 3 s (%d left)" % int(berries.amount)) + await _sim(8.0, func(): return berries.taken) + check(berries.taken, "until they're gone") + check(crow.state == "fly_off", "then it flies off") + await _sim(25.0, func(): return not is_instance_valid(crow) or crow.is_queued_for_deletion()) + check(not is_instance_valid(crow) or crow.is_queued_for_deletion(), "and is gone") + await _clear() + +func _scared() -> void: + print("scaring it off") + var meat := _drop("cooked_meat", 3, site) + await _sim(30.0, func(): return _thief("crow") != null and _thief("crow").state == "peck") + var crow := _thief("crow") + check(crow != null and crow.state == "peck", "a crow lands on some cooked meat") + if crow == null: + return + player.global_position = _ground(site + Vector3(5, 0, 0)) + await _sim(0.5) + check(crow.state == "fly_off" and int(meat.amount) >= 2 and not meat.taken, "walk over and it takes off, leaving the meat (%d)" % int(meat.amount)) + print("shooting it") + crow.take_hit(10.0, player.global_position) + check(crow.is_dead(), "an arrow brings it down") + check(absf(crow.global_position.y - world.terrain.sample_height(crow.global_position.x, crow.global_position.z)) < 0.05, "it falls to the ground") + var got: Dictionary = crow.butcher_drops() + check(int(got.get("feather", 0)) >= 1, "feathers (%s)" % [got]) + await _clear() + +func _safe() -> void: + print("food you're watching") + var by_you := _drop("berries", 5, site) + player.global_position = _ground(site + Vector3(6, 0, 0)) + await _sim(60.0) + check(th.alive().is_empty() and int(by_you.amount) == 5, "a minute with you beside it: nothing comes") + print("food in a chest") + var chest: Node3D = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), _ground(site + Vector3(0, 0, 4)))) + chest.inventory.add("raw_meat", 4) + by_you.queue_free() + player.global_position = _ground(site + Vector3(25, 0, 0)) + await _sim(60.0) + check(th.alive().is_empty() and chest.inventory.count("raw_meat") == 4, "a closed chest keeps it safe") + world.remove_piece(chest) + await _clear() + +func _foxes() -> void: + print("foxes") + _set_time(21.0) + var meat := _drop("raw_meat", 3, site) + await _sim(25.0, func(): return not th.alive().is_empty()) + var fox := _thief("fox") + check(fox != null and _thief("crow") == null, "at night it's a fox that comes") + if fox == null: + return + _toasts.clear() + var t: float = await _sim(30.0, func(): return not fox.carry.is_empty()) + check(meat.taken and int(fox.carry.get("count", 0)) == 3, "it slinks up and takes all 3 meat in one go (%.0f s)" % t) + check(_toast_has("fox has run off"), "you hear it (%s)" % [_toasts]) + await _sim(40.0, func(): return not is_instance_valid(fox) or fox.is_queued_for_deletion()) + check(not is_instance_valid(fox) or fox.is_queued_for_deletion(), "it gets away: the meat's gone") + var found: Array = world.all_dropped().filter(func(d): return d.item_id == "raw_meat") + check(found.is_empty(), "(none left anywhere)") + print("too close") + var fish := _drop("trout", 1, site) + await _sim(25.0, func(): return _thief("fox") != null) + fox = _thief("fox") + if fox != null: + await _sim(4.0) + player.global_position = fox.global_position + Vector3(6, 0, 0) + await _sim(0.5) + check(fox.state == "flee" and fox.carry.is_empty() and not fish.taken, "walk at it while it's creeping in and it bolts empty-handed") + await _clear() + +func _caught() -> void: + print("catching one") + player.global_position = _ground(site + Vector3(25, 0, 0)) + var meat := _drop("cooked_meat", 2, site) + await _sim(25.0, func(): return _thief("fox") != null) + var fox := _thief("fox") + await _sim(30.0, func(): return fox != null and not fox.carry.is_empty()) + check(fox != null and not fox.carry.is_empty(), "a fox runs off with your cooked meat") + if fox == null: + return + await _sim(1.0) + var at: Vector3 = fox.global_position + fox.take_hit(40.0, player.global_position) + check(fox.is_dead(), "you shoot it") + var back: Array = world.all_dropped().filter(func(d): return d.item_id == "cooked_meat" and d.global_position.distance_to(at) < 1.5) + check(back.size() == 1 and int(back[0].amount) == 2, "and it drops the meat where it fell") + check(int(fox.butcher_drops().get("hide", 0)) == 1, "(and has a hide)") + await _clear() + +func _net() -> void: + print("online") + var CN: Node = root.get_node("CreatureNet") + for k in [["crow", "peck"], ["fox", "carry"]]: + var c: Node3D = (th.CrowScript if k[0] == "crow" else th.FoxScript).new() + c.setup(k[0], world, site, 5) + c.thieves = th + c.set_physics_process(false) + th.add_child(c) + c.set_meta("cid", 42) + c.state = k[1] + var buf := PackedByteArray() + buf.resize(CN.ENTRY) + CN._encode(buf, 0, c) + var e: Array = CN._decode(buf, 0) + check(e[1] == k[0] and e[4] == k[1], "a %s goes over the network (%s, %s)" % [k[0], e[1], e[4]]) + c.free()