The Cove's shore life: crabs up the beach from dusk till dawn (keep to the sand, pinch if you get close, crab meat to cook), gulls after food on the sand and fish on a smoking rack; online too

This commit is contained in:
Paul Bailey
2026-11-07 10:23:49 +00:00
parent 5bcfa23710
commit 9709d2c424
30 changed files with 878 additions and 11 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,7 @@
{
"hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7",
"hint_cove": "f7e5c0283b31e1cc",
"hint_crab": "283542496ecd180c",
"hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9",

View File

@@ -37,6 +37,8 @@
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top."}
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top. Mind the gulls: they'll have any food you leave on the sand, and fish off a smoking rack. And at dusk the crabs come up the beach: give them room, or they pinch."},
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
"text": "Two lots of crab meat cook into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
]
}

View File

@@ -593,6 +593,39 @@
},
"keeps": 4
},
{
"id": "crab_meat",
"name": "Crab Meat",
"category": "food",
"stack": 20,
"description": "Sweet white meat from a crab off the Cove's beach. Cook it at a campfire. (Fish)",
"mesh": "crab_meat",
"food": {
"hunger": 4,
"health": -2,
"diet": {
"fish": 8
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "cooked_crab",
"name": "Cooked Crab",
"category": "food",
"stack": 20,
"description": "Boiled pink in its shell. Worth the pinches. (Fish)",
"mesh": "cooked_crab",
"food": {
"hunger": 20,
"health": 10,
"diet": {
"fish": 32
}
},
"keeps": 3
},
{
"id": "grilled_cod",
"name": "Grilled Cod",

View File

@@ -259,6 +259,16 @@
"mackerel": 1
}
},
{
"id": "cooked_crab",
"output": "cooked_crab",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"crab_meat": 2
}
},
{
"id": "grilled_cod",
"output": "grilled_cod",

View File

@@ -16,6 +16,7 @@
],
"climate": {"snowline": 42.0},
"places": [{"type": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}],
"wildlife": ["crabs", "gulls"],
"next": [
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
{"to": "highlands", "by": "sail", "where": "pier"}

View File

@@ -87,6 +87,8 @@ const SOUNDS := {
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
"gull_cry": {"set": [C + "gull_cry_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.12], "reach": 50.0},
"crab_click": {"set": [C + "crab_click_%d.ogg", 2], "db": -6.0, "pitch": [0.85, 1.15], "reach": 14.0},
}
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}

113
scripts/creatures/crab.gd Normal file
View File

@@ -0,0 +1,113 @@
extends "res://scripts/creatures/creature.gd"
## A crab (0.6.0, the Cove only): shore.gd brings them up out of the sea
## onto the beach at dusk. They scuttle about the sand sideways and keep to
## it. Walk within NIP of one and it comes at you, claws up, and pinches
## (not much, but it adds up with three of them about). Leave it NIP_GIVE_UP
## behind and it forgets you. Hit one hard enough and it runs for it. At
## dawn they all go back into the sea. A dead one gives crab meat.
const NIP := 2.2 ## this close and it comes for you
const NIP_GIVE_UP := 6.0 ## this far and it lets you go
const HOME_FOR := 40.0 ## at dawn: this long to reach the water before it simply goes
func interact_prompt(p: Node) -> String:
if is_dead():
return "Crab [E] Crack it open"
match state:
"charge": return "Crab (claws up!)"
"home": return "Crab (heading back to the sea)"
return "Crab (keep your distance, or it'll pinch)"
## Daytime: back to the sea.
func _daytime() -> bool:
var h: float = world.day_night.hours()
return h >= 6.5 and h < 17.5
## Which way the sea is: straight out from the middle of the bay.
func _seaward() -> Vector3:
var a := deg_to_rad(float(terrain.beach_angle_deg))
return Vector3(cos(a), 0.0, sin(a))
func _think(dt: float) -> void:
if state != "home" and state != "flee" and _daytime():
state = "home"
_state_t = HOME_FOR
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
match state:
"home":
var to := _seaward()
steer(atan2(-to.x, -to.z))
_state_t -= dt
if _state_t <= 0.0 or global_position.y < terrain.water_level - 0.25:
queue_free() # (into the waves)
return
"idle", "wander":
if d < NIP:
state = "charge"
_aggro = def.aggro
_attack_cd = 0.5 # (a moment to see it coming)
return
"charge":
if d < NIP_GIVE_UP:
_aggro = def.aggro # (it keeps on at you while you're close)
else:
_aggro = 0.0
super._think(dt)
func _desired_speed() -> float:
if state == "home":
return def.walk * 1.6
return super._desired_speed()
## Keeps to the sand (and out of the water) until it's going home.
func _blocked(dir: Vector3) -> bool:
var probe := global_position + dir * (0.8 + speed * 0.15)
if not terrain.inside_boundary(probe.x, probe.z, 4.0):
return true
if state == "home":
return false # (straight down the beach and into the sea)
if not terrain.on_sand(probe.x, probe.z):
return true
return super._blocked(dir)
## Hit: it fights back while it can, then runs.
func _react(from: Vector3) -> void:
if hp > def.hp * 0.35:
state = "charge"
_aggro = def.aggro
_attack_cd = 0.3
else:
_start_flee(from)
_state_t = 5.0
## Sideways, legs a-flicker; the claws come up when it's after you.
func _animate(dt: float) -> void:
_visual.rotation.y = PI * 0.5 # (it walks sideways)
if is_dead():
return
var k := clampf(speed / maxf(def.run, 0.01), 0.0, 1.0)
_phase += dt * (6.0 + speed * 14.0)
for i in range(_legs.size()):
var l: Node3D = _legs[i]
var s := -1.0 if l.position.x < 0.0 else 1.0
l.rotation.z = s * (1.0 + sin(_phase + i * 1.9) * (0.08 + 0.3 * k))
l.rotation.x = cos(_phase + i * 1.9) * 0.25 * k
var up := 0.12 if state == "charge" or (puppet and speed > 1.5) else 0.0
_visual.rotation.x = lerpf(_visual.rotation.x, -up, minf(dt * 6.0, 1.0))
_visual.position.y = absf(sin(_phase * 0.5)) * 0.01 * k
## A crab doesn't topple onto its side: it ends up on its back.
func _die(_from: Vector3) -> void:
state = "dead"
speed = 0.0
hp = 0.0
_flash = 0.0
_set_material(_mat)
remove_from_group("creature")
add_to_group("carcass")
collision_layer = 2
_visual.rotation = Vector3(0.0, PI * 0.5, PI)
_visual.position.y = 0.1
if not puppet:
died.emit(self)

View File

@@ -0,0 +1 @@
uid://kqumi2x6lfai

View File

@@ -54,6 +54,16 @@ const KINDS := {
"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]}},
# 0.6.0, the Cove only: crabs come up the beach at dusk and pinch anyone
# who walks too close (crab.gd, shore.gd); gulls are the Cove's crows,
# after food left on the sand and fish on a smoking rack (gull.gd).
"crab": {"name": "Crab", "hp": 14.0, "walk": 0.9, "run": 2.6, "turn": 7.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 20.0,
"damage": 5.0, "attack_range": 1.7, "attack_cd": 1.3, "aggro": 5.0,
"drops": {"crab_meat": [1, 2]}},
"gull": {"name": "Gull", "hp": 8.0, "walk": 1.0, "run": 11.0, "turn": 8.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"drops": {"feather": [1, 3]}},
# 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,
@@ -172,7 +182,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
cs.position = Vector3(0, parts.height * 0.5, 0)
if not k in ["rabbit", "guard", "drowned"]: # (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, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.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, "gull": 0.4, "crab": 0.45, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
cs.rotation.x = PI * 0.5
cs.position = Vector3(0, parts.height * 0.55, 0)
add_child(cs)
@@ -525,6 +535,8 @@ const VOICES := {
"drowned": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
"gull": {"alert": "gull_cry", "hurt": "gull_cry", "idle": "gull_cry"},
"crab": {"alert": "crab_click", "attack": "crab_click"},
}
## States a creature is in when it's after someone (music.gd's list too).
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]

View File

@@ -22,6 +22,8 @@ static func build(kind: String) -> Dictionary:
"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()
"gull": d = _gull()
"crab": d = _crab()
"guard": d = _guard()
"drowned": d = _drowned()
"old_stag": d = _scaled(_stag(), 2.1)
@@ -208,6 +210,61 @@ static func _crow() -> Dictionary:
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
"radius": 0.12, "height": 0.3}
## Gull: a crow's build, bigger, white with grey wings and a yellow beak.
static func _gull() -> Dictionary:
var white := Color(0.93, 0.93, 0.9)
var grey := Color(0.6, 0.63, 0.67)
var tip := Color(0.12, 0.12, 0.13)
var beak := Color(0.95, 0.75, 0.2)
var st := P._begin()
var r := _rng(295)
var n := _noise(295, 2.0)
P._blob(st, Vector3(0, 0.2, 0), Vector3(0.09, 0.085, 0.18), n, 0.08, white, r, 1)
P._blob(st, Vector3(0, 0.25, 0.04), Vector3(0.075, 0.05, 0.14), n, 0.06, grey, r, 1)
P._blob(st, Vector3(0, 0.28, -0.16), Vector3(0.06, 0.06, 0.065), n, 0.08, white, r, 1)
P._cylinder(st, Vector3(0, 0.275, -0.21), Vector3(0, 0.255, -0.3), 0.017, 0.004, 4, beak, r)
P._cylinder(st, Vector3(0, 0.22, 0.15), Vector3(0, 0.2, 0.3), 0.035, 0.05, 4, tip, r)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.03, 0.13, 0.0), Vector3(s * 0.035, 0.0, 0.02), 0.008, 0.007, 3, beak, r)
P._blob(st, Vector3(s * 0.04, 0.295, -0.19), Vector3.ONE * 0.009, n, 0.0, Color(0.1, 0.1, 0.1), r, 0)
var wst := P._begin()
P._blob(wst, Vector3(0, -0.2, 0.03), Vector3(0.014, 0.21, 0.09), n, 0.04, grey, r, 0)
P._blob(wst, Vector3(0, -0.38, 0.05), Vector3(0.012, 0.05, 0.06), n, 0.0, tip, r, 0)
return {"body": P._commit(st), "leg": P._commit(wst),
"hips": [Vector3(-0.08, 0.25, -0.01), Vector3(0.08, 0.25, -0.01)],
"radius": 0.15, "height": 0.38}
## Crab: a wide, flat red-brown shell facing -Z, two big claws in front and
## eyes on stalks; six thin legs splayed out to the sides (crab.gd turns the
## whole crab side-on to walk, and flickers the legs).
static func _crab() -> Dictionary:
var shell := Color(0.66, 0.22, 0.12)
var dark := Color(0.42, 0.13, 0.08)
var pale := Color(0.9, 0.72, 0.55)
var st := P._begin()
var r := _rng(297)
var n := _noise(297, 2.4)
P._blob(st, Vector3(0, 0.11, 0), Vector3(0.2, 0.06, 0.14), n, 0.1, shell, r, 1)
P._blob(st, Vector3(0, 0.08, 0.01), Vector3(0.17, 0.035, 0.12), n, 0.05, pale, r, 1)
for s in [-1.0, 1.0]:
# Arm, then the claw: a fat pincer and a thin finger.
P._cylinder(st, Vector3(s * 0.12, 0.1, -0.09), Vector3(s * 0.17, 0.13, -0.2), 0.025, 0.02, 4, shell, r)
P._blob(st, Vector3(s * 0.17, 0.14, -0.26), Vector3(0.05, 0.04, 0.07), n, 0.1, shell, r, 1)
P._cylinder(st, Vector3(s * 0.17, 0.15, -0.31), Vector3(s * 0.15, 0.16, -0.37), 0.02, 0.006, 4, dark, r)
P._cylinder(st, Vector3(s * 0.19, 0.12, -0.31), Vector3(s * 0.18, 0.12, -0.36), 0.014, 0.004, 4, dark, r)
# Eyes on stalks.
P._cylinder(st, Vector3(s * 0.04, 0.15, -0.11), Vector3(s * 0.05, 0.21, -0.13), 0.008, 0.006, 3, shell, r)
P._blob(st, Vector3(s * 0.05, 0.22, -0.13), Vector3.ONE * 0.014, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
var lst := P._begin()
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.11, 0.0), 0.014, 0.01, 3, shell, r)
P._cylinder(lst, Vector3(0, -0.11, 0.0), Vector3(0, -0.2, 0.0), 0.01, 0.003, 3, dark, r)
var hips: Array = []
for s in [-1.0, 1.0]:
for z in [-0.05, 0.03, 0.1]:
hips.append(Vector3(s * 0.17, 0.1, z))
return {"body": P._commit(st), "leg": P._commit(lst), "hips": hips,
"radius": 0.2, "height": 0.25}
## 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).

56
scripts/creatures/gull.gd Normal file
View File

@@ -0,0 +1,56 @@
extends "res://scripts/creatures/crow.gd"
## A gull (0.6.0, the Cove only): the Cove's crow, sent by thieves.gd by
## day. It comes for food left on the sand and for fish on a smoking rack
## that nobody's watching. Bolder and quicker than a crow: it lands, snatches
## one and is off with it, and another soon follows. Shoot it down while
## it's carrying and it drops what it took. Feathers.
const SNATCH_AFTER := 1.2 ## seconds on the ground before it grabs one
const GULL_SCARE := 6.0 ## (bolder than a crow)
var carry: Dictionary = {} ## {id, count, extra} once it has something
func display_name() -> String:
return "Gull"
func interact_prompt(_p: Node) -> String:
if is_dead():
return "Gull [E] Pluck"
if not carry.is_empty():
return "Gull (off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
return {"peck": "Gull (after your food!)", "fly": "Gull (circling your food)"}.get(state, "Gull")
func _think(dt: float) -> void:
match state:
"fly":
if not thieves.gull_still_there(food) or thieves.watched(thieves.spot(food), GULL_SCARE):
_leave()
return
_goal = thieves.spot(food)
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 = SNATCH_AFTER
thieves.gull_landed(self, food)
"peck":
if not thieves.gull_still_there(food) or thieves.watched(global_position, GULL_SCARE):
_leave()
return
_state_t -= dt
if _state_t <= 0.0:
_peck_anim = 0.4
carry = thieves.snatch(self, food)
_leave()
"fly_off":
if thieves.out_of_sight(global_position):
thieves.gone(self)
_:
_leave()
## Shot down with something in its beak: it drops it where it falls.
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 = {}

View File

@@ -0,0 +1 @@
uid://draw1wvqi3cif

106
scripts/creatures/shore.gd Normal file
View File

@@ -0,0 +1,106 @@
extends Node3D
## The shore's own wildlife (0.6.0, a land whose worldlet lists "crabs" in
## its "wildlife": the Cove). From dusk till dawn crabs come up out of the
## sea onto the beach, a few around each player who's down on the sand, at
## the waterline and out of the way. At dawn they go back in (crab.gd).
## Not saved: like the other wildlife they simply carry on around you.
##
## On a server this runs for everyone, and players see the crabs through
## creature_net.gd.
const CrabScript = preload("res://scripts/creatures/crab.gd")
const BUDGET := 5 ## per player on the beach, up to twice it
const CHECK_EVERY := 2.0
const RING_MIN := 14.0
const RING_MAX := 45.0
const NOT_WITHIN := 10.0 ## never comes up right beside anyone
const DESPAWN := 80.0
const BEACH_NEAR := 40.0 ## a player this close to the sand counts as on the beach
var world: Node3D
var terrain: Node3D
var enabled := true
var _rng := RandomNumberGenerator.new()
var _acc := 0.0
var _serial := 0
func setup(w: Node3D) -> void:
world = w
terrain = w.terrain
_rng.seed = terrain.noise_seed + 1201
func _physics_process(delta: float) -> void:
step(delta)
## Dusk till dawn.
func night() -> bool:
var h: float = world.day_night.hours()
return h >= 17.5 or h < 6.5
## Living crabs.
func alive() -> Array:
return get_children().filter(func(c): return is_instance_valid(c) and not c.is_queued_for_deletion() and not c.is_dead())
func step(dt: float) -> void:
if not enabled or world.creatures == null or not world.creatures.enabled:
return
_acc += dt
if _acc < CHECK_EVERY:
return
_acc = 0.0
var everyone: Array = world.players()
for c in get_children():
if not everyone.any(func(p): return p.global_position.distance_to(c.global_position) <= DESPAWN):
c.queue_free()
if not night() or everyone.is_empty():
return
var on_beach := everyone.filter(func(p): return _near_sand(p.global_position))
if on_beach.is_empty():
return
if alive().size() < BUDGET * clampi(on_beach.size(), 1, 2):
var who: Node3D = on_beach[_rng.randi() % on_beach.size()]
spawn_near(who.global_position) # (one at a time: they trickle up out of the sea)
func _near_sand(p: Vector3) -> bool:
for a in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]:
var q := p + Vector3(cos(a), 0.0, sin(a)) * BEACH_NEAR * 0.5
if terrain.on_sand(p.x, p.z) or terrain.on_sand(q.x, q.z):
return true
return false
## A spot on the sand at the waterline, RING_MIN..RING_MAX from `around`
## and not right beside anyone, or Vector3.INF.
func find_spot(around: Vector3) -> Vector3:
for _try in range(40):
var a := _rng.randf() * TAU
var r := _rng.randf_range(RING_MIN, RING_MAX)
var x := around.x + cos(a) * r
var z := around.z + sin(a) * r
if not terrain.inside_boundary(x, z, 6.0) or terrain.beach_weight(x, z) < 0.75:
continue
var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.25 or h > terrain.water_level + 0.9:
continue
var p := Vector3(x, h, z)
if world.players().any(func(pl): return pl.global_position.distance_to(p) < NOT_WITHIN):
continue
return p
return Vector3.INF
func spawn_near(around: Vector3) -> Node3D:
var p := find_spot(around)
if p == Vector3.INF:
return null
return spawn(p)
func spawn(pos: Vector3) -> Node3D:
var c: AnimatableBody3D = CrabScript.new()
_serial += 1
c.setup("crab", world, pos, _rng.randi() + _serial)
add_child(c)
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
c.state = "wander"
c.yaw = _rng.randf() * TAU
c.target_yaw = c.yaw
return c

View File

@@ -0,0 +1 @@
uid://b6eeco4cs028q

View File

@@ -9,10 +9,16 @@ extends Node3D
## fox.gd). Food in a chest is never touched (that's what chests are for);
## the bear (bear_range.gd) is another matter.
##
## In a land with gulls (the Cove, 0.6.0: its worldlet's "wildlife"), food
## left on the sand draws a gull by day instead of a crow (gull.gd), and so
## does fish on a smoking rack that nobody's watching: a gull snatches one
## and flies off with it.
##
## 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 GullScript = preload("res://scripts/creatures/gull.gd")
const CHECK_EVERY := 5.0
const UNWATCHED := 10.0
@@ -25,6 +31,7 @@ signal stolen(thief: Node, id: String, count: int)
var world: Node3D
var enabled := true
var gulls := false ## this land has gulls (the Cove)
var _acc := 0.0
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
var _rng := RandomNumberGenerator.new()
@@ -32,6 +39,7 @@ var _rng := RandomNumberGenerator.new()
func setup(w: Node3D) -> void:
world = w
_rng.seed = w.terrain.noise_seed + 997
gulls = w.has_wildlife("gulls")
func _physics_process(delta: float) -> void:
if enabled:
@@ -85,14 +93,28 @@ func step(dt: float) -> void:
_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)
if gulls and not night():
for r in racks():
if not gull_still_there(r):
continue
var k: int = r.get_instance_id()
seen[k] = true
if watched(r.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(r) and alive().size() < MAX_THIEVES:
send(r)
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.
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow (a
## gull on the sand, or at a smoking rack, where there are gulls).
func send(food: Node3D) -> Node3D:
var fox := night()
var fp: Vector3 = food.global_position
var gull := not fox and gulls and (food.is_in_group("processor") or on_sand(food.global_position))
var fp: Vector3 = spot(food)
var from := Vector3.INF
for _i in range(12):
var a := _rng.randf() * TAU
@@ -104,8 +126,8 @@ func send(food: Node3D) -> Node3D:
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())
var t: AnimatableBody3D = (FoxScript if fox else (GullScript if gull else CrowScript)).new()
t.setup("fox" if fox else ("gull" if gull else "crow"), world, from, _rng.randi())
t.thieves = self
t.food = food
t.state = "sneak" if fox else "fly"
@@ -148,3 +170,63 @@ func grab(fox: Node, food: Node3D) -> Dictionary:
## A thief left with what it took (or empty-handed).
func gone(t: Node) -> void:
t.queue_free()
# --- gulls (the Cove) ----------------------------------------------------------------
func on_sand(pos: Vector3) -> bool:
return world.terrain.on_sand(pos.x, pos.z)
static func is_fish(id: String) -> bool:
return Dictionary(ItemDB.get_item(id).get("food", {})).get("diet", {}).has("fish")
## This land's smoking racks.
func racks() -> Array:
return get_tree().get_nodes_in_group("processor").filter(func(r): return is_instance_valid(r) and not r.is_queued_for_deletion() \
and String(r.item_id) == "smoking_rack" and world.is_ancestor_of(r))
## Fish on a rack: the first id there is, or "".
func _rack_fish(r: Node3D) -> String:
for st in r.inventory.slots:
if st != null and is_fish(String(st.id)):
return String(st.id)
return ""
## Whether a gull's target is still worth it: food on the ground, or a rack
## with fish on it.
func gull_still_there(t: Node3D) -> bool:
if t == null or not is_instance_valid(t) or t.is_queued_for_deletion():
return false
if t.is_in_group("processor"):
return not _rack_fish(t).is_empty()
return still_there(t)
## Where a thief lands for `t`: on food, or on the ground beside a rack.
func spot(t: Node3D) -> Vector3:
if t.is_in_group("processor"):
var p: Vector3 = t.global_position + t.global_basis.z * 0.8
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
return t.global_position
func gull_landed(_gull: Node, t: Node3D) -> void:
if t.is_in_group("processor"):
_tell(t.global_position, "A gull's at the fish on your smoking rack!")
else:
_tell(t.global_position, "A gull's landed by your %s!" % ItemDB.item_name(t.item_id).to_lower())
## A gull takes one and flies off with it. Returns what it's carrying.
func snatch(gull: Node, t: Node3D) -> Dictionary:
if not gull_still_there(t):
return {}
var c := {}
if t.is_in_group("processor"):
var id := _rack_fish(t)
t.inventory.remove(id, 1)
c = {"id": id, "count": 1, "extra": {}}
else:
c = {"id": String(t.item_id), "count": 1, "extra": t.extra.duplicate()}
t.amount = int(t.amount) - 1
if int(t.amount) <= 0:
world.take_dropped(t)
_tell(t.global_position, "A gull has snatched your %s!" % ItemDB.item_name(String(c.id)).to_lower())
stolen.emit(gull, String(c.id), 1)
return c

View File

@@ -84,6 +84,8 @@ static func build(key: String) -> ArrayMesh:
"mackerel": m = _fish(MACKEREL, 0.2, false)
"cod": m = _fish(COD, 0.3, false)
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
"crab_meat": m = _crab_meat()
"cooked_crab": m = _cooked_crab()
"grilled_cod": m = _fish(COD, 0.3, true)
"fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
@@ -1179,6 +1181,34 @@ static func _barrow_key() -> ArrayMesh:
# --- P4 food keeping ---------------------------------------------------------------------
## A crust of white salt crystals.
## A few chunks of white crab meat in a cracked bit of shell.
static func _crab_meat() -> ArrayMesh:
var st := P._begin()
var r := _rng(341)
var n := FastNoiseLite.new()
n.seed = 341
n.frequency = 2.6
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.11, 0.03, 0.08), n, 0.2, Color(0.62, 0.22, 0.12), r, 1)
for i in range(4):
var c := Vector3(r.randf_range(-0.05, 0.05), 0.06, r.randf_range(-0.035, 0.035))
P._blob(st, c, Vector3(0.035, 0.022, 0.025), n, 0.25, Color(0.95, 0.9, 0.84).lerp(Color(0.95, 0.7, 0.62), r.randf() * 0.4), r, 0)
return P._commit(st)
## A whole crab, boiled pink-red, legs tucked.
static func _cooked_crab() -> ArrayMesh:
var st := P._begin()
var r := _rng(343)
var n := FastNoiseLite.new()
n.seed = 343
n.frequency = 2.4
var shell := Color(0.9, 0.35, 0.22)
P._blob(st, Vector3(0, 0.05, 0), Vector3(0.12, 0.04, 0.09), n, 0.1, shell, r, 1)
for sx in [-1.0, 1.0]:
P._blob(st, Vector3(sx * 0.1, 0.04, -0.1), Vector3(0.03, 0.025, 0.045), n, 0.1, shell, r, 0)
for z in [-0.03, 0.01, 0.05]:
P._cylinder(st, Vector3(sx * 0.1, 0.03, z), Vector3(sx * 0.15, 0.01, z + 0.02), 0.008, 0.004, 3, P._mul(shell, 0.85), r)
return P._commit(st)
static func _salt() -> ArrayMesh:
var st := P._begin()
var r := _rng(331)

View File

@@ -21,6 +21,8 @@ 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")
const GullScript = preload("res://scripts/creatures/gull.gd")
const CrabScript = preload("res://scripts/creatures/crab.gd")
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
const Bobber = preload("res://scripts/fishing/bobber.gd")
const Projectile = preload("res://scripts/creatures/projectile.gd")
@@ -99,7 +101,7 @@ func _server_tick(w: Node3D, delta: float) -> void:
const PER_PACKET := 40
const ENTRY := 23
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned"]
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
@@ -198,7 +200,8 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
var c: Node3D = got if is_instance_valid(got) else null
if c == null:
# (Kinds with their own look: the bear rears up, a crow flaps.)
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript}.get(kind, Creature)
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript,
"gull": GullScript, "crab": CrabScript}.get(kind, Creature)
c = script.new()
c.puppet = true
c.setup(kind, world, e[2], cid)

View File

@@ -533,7 +533,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", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned"]) 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", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]) and _v(a, 1).is_finite():
w.creatures.spawn(_s(a, 0), _v(a, 1))
"debug_boss":
# Tests: ["hold", secs] stands the Old Stag still (stunned);

View File

@@ -768,6 +768,14 @@ func sea_weight(x: float, z: float) -> float:
_ensure_noise()
return _sea_w(x, z)
## Whether (x, z) is on the sand (0.6.0): the beach side of the rim, low
## enough for the sandy shoreline (terrain.gdshader paints sand below
## water_level + SAND_TOP), or under the water off it.
const SAND_TOP := 1.2
func on_sand(x: float, z: float) -> bool:
_ensure_noise()
return _beach_w(x, z) > 0.5 and sample_height(x, z) < water_level + SAND_TOP
## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
func beach_weight(x: float, z: float) -> float:
_ensure_noise()

View File

@@ -74,6 +74,7 @@ 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 shore: Node3D ## shore.gd: the Cove's crabs (null in a land without them)
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
@@ -513,6 +514,12 @@ func _setup_bear() -> void:
thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
if barrow != null and barrow.found and thieves.enabled:
barrow.start()
if has_wildlife("crabs"):
shore = preload("res://scripts/creatures/shore.gd").new()
shore.name = "Shore"
add_child(shore)
shore.setup(self)
shore.enabled = thieves.enabled
if not has_place("bear"):
return # (bears only in the lands that list one: the Valley)
bears = preload("res://scripts/creatures/bear_range.gd").new()
@@ -1314,6 +1321,11 @@ func has_place(kind: String) -> bool:
return kind in DEFAULT_PLACES
return places.any(func(p): return p is Dictionary and String(p.get("type", "")) == kind)
## Whether this land has wildlife `kind` beyond the usual ("crabs",
## "gulls"): its worldlet's "wildlife" list (0.6.0; the Cove's).
func has_wildlife(kind: String) -> bool:
return kind in Array(terrain.worldlet.get("wildlife", []))
## This worldlet's id ("valley") and its whole definition.
func worldlet_id() -> String:
return terrain.worldlet_id

View File

@@ -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_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_tunnel test_map 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_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_tunnel test_map 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,

312
tests/test_shore.gd Normal file
View File

@@ -0,0 +1,312 @@
extends SceneTree
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's own wildlife. Crabs come
## up the beach at dusk (never in the Valley, never by day), keep to the
## sand, pinch anyone who walks too close and let them go once they back
## off; a dead one gives crab meat, which cooks; at dawn they go back into
## the sea. Gulls are the Cove's crows: food left on the sand draws a gull
## (inland it's still a crow), and so does fish on a smoking rack nobody's
## watching; a gull snatches one and flies off, and drops it if you shoot it.
## Run: godot --headless --path . --script res://tests/test_shore.gd
var _fails := 0
var _finished := false
var S: Node
var A: Node
var DB: Node
var _toasts: Array[String] = []
var w: Node3D
var player: CharacterBody3D
var sh: Node
var th: Node
var beach := Vector3.ZERO ## a spot on the sand, just above the waterline
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:
S = root.get_node("Saves")
A = root.get_node("Actions")
DB = root.get_node("ItemDB")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_shore_worlds"
for x in S.list_worlds():
S.delete_world(x.id)
await _body()
check(_finished, "test ran to the end (no script errors)")
for x in S.list_worlds():
S.delete_world(x.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() -> Node3D:
var x: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(x)
for _i in range(8):
await physics_frame
return x
func _set_time(hours: float) -> void:
w.day_night.day = 3
w.day_night.time = hours / 24.0
w.day_night.apply(true)
func _ground(p: Vector3) -> Vector3:
return Vector3(p.x, w.terrain.sample_height(p.x, p.z), p.z)
func _toast_has(s: String) -> bool:
return _toasts.any(func(t): return String(t).contains(s))
## Runs the crabs (shore.gd and each crab) for up to `secs`.
func _sim_crabs(secs: float, until: Callable = Callable()) -> float:
var t := 0.0
while t < secs:
sh.step(0.1)
for c in sh.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
## Runs the thieves for up to `secs`.
func _sim_thieves(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 _thief(kind: String) -> Node3D:
for c in th.alive():
if c.kind == kind:
return c
return null
func _clear_thieves() -> void:
for c in th.get_children():
c.queue_free()
for d in w.all_dropped():
d.queue_free()
await physics_frame
## A spot on the sand just above the waterline, in the middle of the bay.
func _find_beach() -> Vector3:
var t: Node3D = w.terrain
var a := deg_to_rad(float(t.beach_angle_deg))
for off in [0.0, 0.25, -0.25, 0.5, -0.5]:
for r in range(20, 140):
var x := cos(a + off) * r
var z := sin(a + off) * r
var h: float = t.sample_height(x, z)
if t.beach_weight(x, z) > 0.85 and h > t.water_level + 0.5 and h < t.water_level + 0.9:
return Vector3(x, h, z)
return Vector3.INF
func _body() -> void:
print("the Valley")
S.current_id = S.create_world("Valley", 1337)
w = await _load()
check(w.shore == null, "no crabs in the Valley")
check(w.thieves != null and not w.thieves.gulls, "and no gulls")
w.queue_free()
for _i in range(3):
await process_frame
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
w = await _load()
player = w.player
player.set_physics_process(false)
player.survival.set_process(false)
w.day_night.frozen = true
w.weather.set_process(false)
w.weather.force("clear")
if w.wolves != null:
w.wolves.enabled = false
for c in w.creatures.alive():
c.queue_free()
sh = w.shore
th = w.thieves
check(sh != null and th.gulls, "the Cove has crabs and gulls")
if sh == null:
return
sh.set_physics_process(false)
th.enabled = false
w.creatures.set_process(false) # (keeps rabbits out of the way; creatures.enabled stays true)
beach = _find_beach()
check(beach != Vector3.INF, "a spot on the beach (%s)" % [beach])
if beach == Vector3.INF:
return
await _crabs()
await _gulls()
_net()
_finished = true
func _crabs() -> void:
print("crabs")
_set_time(12.0)
player.global_position = beach
await _sim_crabs(10.0)
check(sh.alive().is_empty(), "none by day")
_set_time(18.0)
await _sim_crabs(20.0, func(): return sh.alive().size() >= sh.BUDGET)
var crabs: Array = sh.alive()
check(crabs.size() == sh.BUDGET, "at dusk they come up the beach (%d)" % crabs.size())
var t: Node3D = w.terrain
check(crabs.all(func(c): return t.on_sand(c.global_position.x, c.global_position.z)),
"on the sand at the waterline (%s)" % [crabs.map(func(c): return "%.2f" % (c.global_position.y - t.water_level))])
check(crabs.all(func(c): return c.global_position.distance_to(beach) >= sh.NOT_WITHIN), "and not right beside you")
print("keeping to the sand")
player.global_position = beach
await _sim_crabs(40.0)
crabs = sh.alive()
check(crabs.size() >= 3 and crabs.all(func(c): return t.water_level + 0.1 < c.global_position.y and c.global_position.y < t.water_level + t.SAND_TOP + 0.2),
"40 s on, they're all still on the sand (%d)" % crabs.size())
print("a pinch")
for c in crabs:
c.queue_free()
await physics_frame
var crab: Node3D = sh.spawn(beach + Vector3(1.6, 0, 0))
crab.set_physics_process(false)
player.global_position = _ground(beach)
player.survival.health = player.survival.MAX
var h0: float = player.survival.health
sh.enabled = false # (just this one)
await _sim_crabs(3.0)
check(crab.state == "charge", "walk within a couple of metres and it comes at you")
check(player.survival.health < h0 and player.survival.health > h0 - 20.0, "and pinches (%.0f -> %.0f)" % [h0, player.survival.health])
check(crab.interact_prompt(player).contains("claws up"), "(\"%s\")" % crab.interact_prompt(player))
player.global_position = _ground(crab.global_position + Vector3(9, 0, 0))
await _sim_crabs(8.0)
check(crab.state in ["wander", "idle"], "back off and it lets you go (%s)" % crab.state)
var h1: float = player.survival.health
await _sim_crabs(5.0)
check(is_equal_approx(player.survival.health, h1), "no more pinches from over there")
print("crab supper")
crab.take_hit(20.0, player.global_position)
check(crab.is_dead() and crab.interact_prompt(player).contains("Crack it open"), "a blow kills it: [E] Crack it open")
player.global_position = _ground(crab.global_position + Vector3(0.8, 0, 0))
var before: int = player.inventory.count("crab_meat")
A.butcher(player, crab)
var got: int = player.inventory.count("crab_meat") - before
check(got >= 1 and got <= 2, "crab meat (%d)" % got)
check(DB.get_item("crab_meat").has("food") and DB.get_item("cooked_crab").has("food"), "it's food, raw and cooked")
var r: Dictionary = DB.recipes.get("cooked_crab", {})
check(not r.is_empty() and String(r.get("station", "")) == "campfire" and int(Dictionary(r.inputs).get("crab_meat", 0)) == 2,
"two of it cook into a Cooked Crab at a campfire")
print("dawn")
sh.enabled = true
_set_time(19.0)
await _sim_crabs(20.0, func(): return sh.alive().size() >= 3)
check(sh.alive().size() >= 3, "(a few more come up)")
_set_time(7.0)
await _sim_crabs(2.0)
check(sh.alive().all(func(c): return c.state == "home"), "at dawn they head back to the sea")
await _sim_crabs(45.0, func(): return sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty())
check(sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty(), "and are gone")
await _sim_crabs(10.0)
check(sh.alive().is_empty(), "none come up by day")
sh.enabled = false
func _gulls() -> void:
print("gulls on the sand")
_set_time(12.0)
_toasts.clear()
var fish: Node3D = w.drop_item("mackerel", 3, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
player.global_position = _ground(beach + Vector3(-25, 0, 0))
check(not th.on_sand(w.build.debug_find_site(w._spawn, 6.0)), "(the grass above the beach isn't sand)")
await _sim_thieves(30.0, func(): return _thief("gull") != null or _thief("crow") != null)
var gull := _thief("gull")
check(gull != null and _thief("crow") == null, "food left on the sand draws a gull, not a crow")
if gull == null:
return
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
check(String(gull.carry.get("id", "")) == "mackerel" and int(fish.amount) == 2, "it snatches one mackerel (%d left)" % int(fish.amount))
check(_toast_has("gull has snatched"), "and you hear about it (%s)" % [_toasts])
check(gull.state == "fly_off", "then it's off with it")
print("shooting one")
await _sim_thieves(1.0)
gull.take_hit(10.0, player.global_position)
check(gull.is_dead(), "an arrow brings it down")
var back: Array = w.all_dropped().filter(func(d): return d.item_id == "mackerel" and d != fish)
check(back.size() == 1 and int(back[0].amount) == 1, "and it drops the mackerel")
check(int(gull.butcher_drops().get("feather", 0)) >= 1, "(feathers)")
await _clear_thieves()
print("inland")
var site: Vector3 = w.build.debug_find_site(w._spawn, 6.0)
if th.on_sand(site):
site = _ground(Vector3(-40, 0, -10))
var berries: Node3D = w.drop_item("berries", 3, Transform3D(Basis(), _ground(site) + Vector3(0, 0.15, 0)))
player.global_position = _ground(site + Vector3(25, 0, 0))
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
check(_thief("crow") != null and _thief("gull") == null, "food left inland still draws a crow")
berries.queue_free()
await _clear_thieves()
print("a smoking rack")
var rp := _ground(site + Vector3(3, 0, 3))
var rack: Node3D = w.spawn_piece(DB.get_item("smoking_rack"), Transform3D(Basis(), rp))
rack.set_process(false)
rack.inventory.add("mackerel", 1) # (one: not enough to smoke, so it stays raw)
rack.inventory.add("smoked_fish", 2)
player.global_position = _ground(rp + Vector3(6, 0, 0))
await _sim_thieves(40.0)
check(th.alive().is_empty(), "with you beside it, nothing comes")
player.global_position = _ground(rp + Vector3(25, 0, 0))
_toasts.clear()
await _sim_thieves(30.0, func(): return _thief("gull") != null)
gull = _thief("gull")
check(gull != null, "left alone, a gull comes for the fish on it")
if gull != null:
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
check(rack.inventory.count("mackerel") + rack.inventory.count("smoked_fish") == 2, "and takes one (%s)" % [gull.carry])
check(_toast_has("smoking rack"), "(\"A gull's at the fish on your smoking rack!\")")
await _clear_thieves()
rack.inventory.remove("mackerel", 5)
rack.inventory.remove("smoked_fish", 5)
rack.inventory.add("wood", 2)
await _sim_thieves(40.0)
check(th.alive().is_empty(), "no fish on it, no gull")
print("at night")
_set_time(22.0)
var meat: Node3D = w.drop_item("raw_meat", 2, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
player.global_position = _ground(beach + Vector3(25, 0, 0))
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
check(_thief("fox") != null and _thief("gull") == null, "at night it's a fox, sand or not")
meat.queue_free()
await _clear_thieves()
func _net() -> void:
print("online")
var CN: Node = root.get_node("CreatureNet")
for k in [["crab", "home"], ["crab", "charge"], ["gull", "peck"]]:
var c: Node3D = (sh.CrabScript if k[0] == "crab" else th.GullScript).new()
c.setup(k[0], w, beach, 5)
c.set_physics_process(false)
w.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()

1
tests/test_shore.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cydvl5o72vanm

View File

@@ -616,6 +616,29 @@ def make_voices():
_encode(os.path.join(d, "fox_yip_%d.ogg" % i),
[voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5),
[(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5)
# 0.6.0, the Cove: a gull's "kee-ow" (and a laughing "ha-ha-ha"), and a
# crab's claws clicking.
_encode(os.path.join(d, "gull_cry_0.ogg"),
[voice(210, 0.55, lambda q: 1350 - 500 * q ** 1.4 + 120 * sin(math.pi * min(1, q * 3)), _swell(0.06, 0.55),
[(1700, 4, 1.0), (3300, 5, 0.5)], breath=0.25, rasp=0.25, rasp_hz=70, jitter=0.03)], SR_FX, 5)
hs = [0.0, 0.14, 0.28, 0.42]
def genv(q, ps=hs):
t = q * 0.6
return max(max(0.0, min(1, (t - p) / 0.015)) * max(0.0, 1 - (t - p) / 0.11) if t >= p else 0.0 for p in ps)
_encode(os.path.join(d, "gull_cry_1.ogg"),
[voice(211, 0.6, lambda q: 1150 - 150 * q, genv, [(1600, 4, 1.0), (3100, 5, 0.5)],
breath=0.3, rasp=0.3, rasp_hz=70, jitter=0.03)], SR_FX, 5)
for i in range(2):
rng = random.Random(220 + i)
cb = fx_buf(0.3)
for t in [0.0, 0.07, 0.11, 0.2][: 2 + i * 2]:
i0 = int(t * SR_FX)
f = rng.uniform(2600, 3600)
for j in range(int(0.012 * SR_FX)):
if i0 + j < len(cb):
cb[i0 + j] += sin(TAU * f * j / SR_FX) * exp(-j / 60) * 0.9
add_noise(cb, SR_FX, t, 0.004, 0.7, lp=0.95, hp=0.4, env=lambda q: 1 - q, rng=rng)
_encode(os.path.join(d, "crab_click_%d.ogg" % i), [cb], SR_FX, 5)
def make_sfx():