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)

This commit is contained in:
Paul Bailey
2026-11-05 09:23:31 +00:00
parent 96aac29606
commit 9528f91e47
20 changed files with 1695 additions and 21 deletions

View File

@@ -79,13 +79,14 @@
"name": "Wooden Club", "name": "Wooden Club",
"category": "weapon", "category": "weapon",
"stack": 1, "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", "mesh": "club",
"weapon": { "weapon": {
"damage": 12, "damage": 12,
"reach": 2.4, "reach": 2.4,
"stamina": 8, "stamina": 8,
"cooldown": 0.55 "cooldown": 0.55,
"blunt": true
}, },
"durability": 80, "durability": 80,
"repair": { "repair": {
@@ -969,6 +970,14 @@
2.2 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"
} }
] ]
} }

View File

@@ -1,7 +1,7 @@
# Progression and challenges design # Progression and challenges design
Status: **P1 (iron) and P2 (stone) built; P3 started (the alpha)**; the Status: **P1 (iron), P2 (stone) and P3 (adversaries) built**; P4-P5 are
rest of P3, and P4-P5, are proposals. proposals.
*As built in P1:* *As built in P1:*
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake - 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 - 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 rug (bear hide, 2 fibre): in a sheltered room, Cozy: +4 warmth and
Rested twice as fast. Network protocol 8. 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 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 tools, a hut, a bow and a fire, the only threats are wolves at night, the

View File

@@ -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] 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 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] 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, - [ ] **Iron, stone building and new adversaries**: iron nuggets and ore,
smelting and a forge, iron tools and durability, cut-stone building, smelting and a forge, iron tools and durability, cut-stone building,
an alpha wolf, a bear, a barrow with guards, spoilage and blizzards an alpha wolf, a bear, a barrow with guards, spoilage and blizzards

View File

@@ -305,6 +305,24 @@ func toggle_door(player: Node3D, door: Node3D) -> bool:
door.set_open(not door.open) door.set_open(not door.open)
return true 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. ## E on a door an alpha broke: mend it (two wood), shut.
func repair_door(player: Node3D, door: Node3D) -> bool: 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): 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", {}) var w: Dictionary = held.get("weapon", {})
if float(w.get("damage", 0)) > 0.0: if float(w.get("damage", 0)) > 0.0:
return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)), 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"): if held.has("tool"):
return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true} return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true}
return NO_WEAPON return NO_WEAPON
@@ -384,7 +403,9 @@ func melee(player: Node3D, target: Node3D) -> bool:
if not player.survival.use_stamina(m.stamina): if not player.survival.use_stamina(m.stamina):
notify.emit("Too tired") notify.emit("Too tired")
return false 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) use_held(player)
if target.is_dead(): if target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower()) notify.emit("Killed a %s" % target.display_name().to_lower())

View File

@@ -61,6 +61,8 @@ func _find_home() -> bool:
continue 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: 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 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: if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0:
continue continue
var near := 0 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!" what = "A bear has broken into your chest and eaten the food!"
else: else:
eaten = int(target.amount) eaten = int(target.amount)
target.collected() world.take_dropped(target)
what = "A bear is eating the food you left out!" what = "A bear is eating the food you left out!"
for p in world.players(): for p in world.players():
if p.global_position.distance_to(target.global_position) < 80.0: if p.global_position.distance_to(target.global_position) < 80.0:

View File

@@ -46,6 +46,20 @@ const KINDS := {
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0, "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, "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]}}, "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). # 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. # 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, "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() var eyes := MeshInstance3D.new()
eyes.mesh = parts.eyes eyes.mesh = parts.eyes
var em := StandardMaterial3D.new() var em := StandardMaterial3D.new()
var red := k == "alpha" 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 = Color(1.0, 0.3, 0.2) if red else Color(1.0, 0.85, 0.4) em.albedo_color = eye.lerp(Color.WHITE, 0.2)
em.emission_enabled = true 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 em.emission_energy_multiplier = 2.5
eyes.material_override = em eyes.material_override = em
eyes.set_meta("keep_mat", true) 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) cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
cs.shape = cap cs.shape = cap
cs.position = Vector3(0, parts.height * 0.5, 0) 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. # 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.rotation.x = PI * 0.5
cs.position = Vector3(0, parts.height * 0.55, 0) cs.position = Vector3(0, parts.height * 0.55, 0)
add_child(cs) add_child(cs)

View File

@@ -20,6 +20,9 @@ static func build(kind: String) -> Dictionary:
"wolf": d = _wolf() "wolf": d = _wolf()
"alpha": d = _wolf(true) "alpha": d = _wolf(true)
"bear": d = _bear() "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() _: d = _boar()
_cache[kind] = d _cache[kind] = d
return 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), 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)], "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} "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}

115
scripts/creatures/crow.gd Normal file
View File

@@ -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

95
scripts/creatures/fox.gd Normal file
View File

@@ -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 = {}

110
scripts/creatures/guard.gd Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -115,6 +115,7 @@ static func build(key: String) -> ArrayMesh:
"bear_hide": m = _pelt(BEAR_FUR) "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) "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() "fur_rug": m = _rug()
"barrow_key": m = _barrow_key()
"berry_bush": m = _berry_bush(true) "berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false) "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(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) _box(st, Vector3(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR)
return P._commit(st) 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)

View File

@@ -19,6 +19,8 @@ extends Node
const Creature = preload("res://scripts/creatures/creature.gd") const Creature = preload("res://scripts/creatures/creature.gd")
const BearScript = preload("res://scripts/creatures/bear.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 Bobber = preload("res://scripts/fishing/bobber.gd")
const Projectile = preload("res://scripts/creatures/projectile.gd") const Projectile = preload("res://scripts/creatures/projectile.gd")
const WolfPack = preload("res://scripts/creatures/wolf_pack.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 PER_PACKET := 40
const ENTRY := 23 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", 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. ## One creature in 23 bytes: id, kind, state, position, facing, speed, health.
static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void: 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 got: Variant = puppets.get(cid)
var c: Node3D = got if is_instance_valid(got) else null var c: Node3D = got if is_instance_valid(got) else null
if c == 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.puppet = true
c.setup(kind, world, e[2], cid) c.setup(kind, world, e[2], cid)
c.set_meta("cid", cid) c.set_meta("cid", cid)

View File

@@ -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 Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.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 DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0 const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join

View File

@@ -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)) \ if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \
and Actions.toggle_door(p, d): and Actions.toggle_door(p, d):
WorldNet.piece_state(d) WorldNet.piece_state(d)
"barrow_loot":
if w.barrow != null:
Actions.loot_barrow(p, w.barrow)
"door_repair": "door_repair":
var d := WorldNet.piece(_i(a, 0)) var d := WorldNet.piece(_i(a, 0))
if d != null and d.get("broken") != null and _allowed(Rules.use_problem(p.player_id, d)) \ 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"): 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) m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
"debug_spawn": "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)) w.creatures.spawn(_s(a, 0), _v(a, 1))
"debug_time": "debug_time":
if cheats: if cheats:

449
scripts/world/barrow.gd Normal file
View File

@@ -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)

View File

@@ -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 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 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 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 _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 net_sync: Node ## net_sync.gd when playing on a server, else null
var has_bed_spawn := false var has_bed_spawn := false
@@ -340,6 +342,13 @@ func _on_terrain_ready() -> void:
if den.place(self, props._rock_mat): if den.place(self, props._rock_mat):
props.static_cuts.append([den.global_transform, 2.2, 3.4]) props.static_cuts.append([den.global_transform, 2.2, 3.4])
_update_grass_cuts() _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": if DisplayServer.get_name() != "headless":
var fish := preload("res://scripts/fishing/lake_fish.gd").new() var fish := preload("res://scripts/fishing/lake_fish.gd").new()
fish.name = "LakeFish" fish.name = "LakeFish"
@@ -392,9 +401,17 @@ func _on_terrain_ready() -> void:
_setup_bear() _setup_bear()
_maybe_capture() _maybe_capture()
## The island's bear and its patch (bear_range.gd). The server runs it for ## The island's bear and its patch (bear_range.gd), and the crows and foxes
## everyone; players' games see it through creature_net.gd. ## after food left out (thieves.gd). The server runs them for everyone;
## players' games see them through creature_net.gd.
func _setup_bear() -> void: 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 = preload("res://scripts/creatures/bear_range.gd").new()
bears.name = "Bear" bears.name = "Bear"
add_child(bears) add_child(bears)
@@ -526,6 +543,7 @@ func collect_save() -> Dictionary:
"pickups_taken": taken, "pickups_taken": taken,
"pieces": pieces, "pieces": pieces,
"bear": bears.save_data() if bears != null else _saved_bear, "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: 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, "pickups_taken": taken,
"pieces": pieces, "pieces": pieces,
"bear": bears.save_data() if bears != null else _saved_bear, "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). ## 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() props.pickups[uid].collected()
# Regrowth timers (older saves have none: everything gone starts a fresh timer). # Regrowth timers (older saves have none: everything gone starts a fresh timer).
props.restore_regrow(s.get("regrow", {})) 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. # The bear (bear_range.gd): applied when it's set up, after loading.
_saved_bear = s.get("bear", {}) _saved_bear = s.get("bear", {})
if bears != null: if bears != null:
@@ -816,6 +837,13 @@ func drop_item(id: String, count: int, xf: Transform3D, extra: Dictionary = {})
return null return null
return _make_dropped(id, count, xf, extra) 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: func _make_dropped(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
var pk: Area3D = preload("res://scripts/world/pickup.gd").new() var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3) pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi 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="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

312
tests/test_barrow.gd Normal file
View File

@@ -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]])

245
tests/test_thieves.gd Normal file
View File

@@ -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()