diff --git a/assets/audio/sfx/creatures/crab_click_0.ogg b/assets/audio/sfx/creatures/crab_click_0.ogg new file mode 100644 index 0000000..3f65f5f Binary files /dev/null and b/assets/audio/sfx/creatures/crab_click_0.ogg differ diff --git a/assets/audio/sfx/creatures/crab_click_1.ogg b/assets/audio/sfx/creatures/crab_click_1.ogg new file mode 100644 index 0000000..0356703 Binary files /dev/null and b/assets/audio/sfx/creatures/crab_click_1.ogg differ diff --git a/assets/audio/sfx/creatures/gull_cry_0.ogg b/assets/audio/sfx/creatures/gull_cry_0.ogg new file mode 100644 index 0000000..289b4ea Binary files /dev/null and b/assets/audio/sfx/creatures/gull_cry_0.ogg differ diff --git a/assets/audio/sfx/creatures/gull_cry_1.ogg b/assets/audio/sfx/creatures/gull_cry_1.ogg new file mode 100644 index 0000000..75d83c5 Binary files /dev/null and b/assets/audio/sfx/creatures/gull_cry_1.ogg differ diff --git a/assets/audio/voice/hint_cove.ogg b/assets/audio/voice/hint_cove.ogg index f2a142c..628813e 100644 Binary files a/assets/audio/voice/hint_cove.ogg and b/assets/audio/voice/hint_cove.ogg differ diff --git a/assets/audio/voice/hint_crab.ogg b/assets/audio/voice/hint_crab.ogg new file mode 100644 index 0000000..f758b17 Binary files /dev/null and b/assets/audio/voice/hint_crab.ogg differ diff --git a/assets/audio/voice/lines.json b/assets/audio/voice/lines.json index a579cdf..50b7452 100644 --- a/assets/audio/voice/lines.json +++ b/assets/audio/voice/lines.json @@ -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", diff --git a/data/hints.json b/data/hints.json index b47e723..2828228 100644 --- a/data/hints.json +++ b/data/hints.json @@ -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."} ] } diff --git a/data/items.json b/data/items.json index 620a0e8..769f63b 100644 --- a/data/items.json +++ b/data/items.json @@ -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", diff --git a/data/recipes.json b/data/recipes.json index 75ed5ee..ec940d0 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -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", diff --git a/data/worldlets/cove.json b/data/worldlets/cove.json index f14c9bf..2a1b71e 100644 --- a/data/worldlets/cove.json +++ b/data/worldlets/cove.json @@ -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"} diff --git a/scripts/core/sfx.gd b/scripts/core/sfx.gd index 10ffcbf..f34901d 100644 --- a/scripts/core/sfx.gd +++ b/scripts/core/sfx.gd @@ -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} diff --git a/scripts/creatures/crab.gd b/scripts/creatures/crab.gd new file mode 100644 index 0000000..d002f08 --- /dev/null +++ b/scripts/creatures/crab.gd @@ -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) diff --git a/scripts/creatures/crab.gd.uid b/scripts/creatures/crab.gd.uid new file mode 100644 index 0000000..d87c171 --- /dev/null +++ b/scripts/creatures/crab.gd.uid @@ -0,0 +1 @@ +uid://kqumi2x6lfai diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 52c333d..6889e73 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -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"] diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index 8232c87..350f465 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -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). diff --git a/scripts/creatures/gull.gd b/scripts/creatures/gull.gd new file mode 100644 index 0000000..2929d76 --- /dev/null +++ b/scripts/creatures/gull.gd @@ -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 = {} diff --git a/scripts/creatures/gull.gd.uid b/scripts/creatures/gull.gd.uid new file mode 100644 index 0000000..4cdbca5 --- /dev/null +++ b/scripts/creatures/gull.gd.uid @@ -0,0 +1 @@ +uid://draw1wvqi3cif diff --git a/scripts/creatures/shore.gd b/scripts/creatures/shore.gd new file mode 100644 index 0000000..bee6652 --- /dev/null +++ b/scripts/creatures/shore.gd @@ -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 diff --git a/scripts/creatures/shore.gd.uid b/scripts/creatures/shore.gd.uid new file mode 100644 index 0000000..683b8fd --- /dev/null +++ b/scripts/creatures/shore.gd.uid @@ -0,0 +1 @@ +uid://b6eeco4cs028q diff --git a/scripts/creatures/thieves.gd b/scripts/creatures/thieves.gd index 09767f3..a950c8d 100644 --- a/scripts/creatures/thieves.gd +++ b/scripts/creatures/thieves.gd @@ -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 diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 58cc599..5e49c1d 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -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) diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd index d19cd4b..8be4411 100644 --- a/scripts/net/creature_net.gd +++ b/scripts/net/creature_net.gd @@ -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) diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 6d7685a..d5c2e2e 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -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); diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 621e173..3625803 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -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() diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 2141bdf..fedf49c 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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 diff --git a/tests/run_all.sh b/tests/run_all.sh index 29d31ca..c50dd71 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_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, diff --git a/tests/test_shore.gd b/tests/test_shore.gd new file mode 100644 index 0000000..732cf2c --- /dev/null +++ b/tests/test_shore.gd @@ -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() diff --git a/tests/test_shore.gd.uid b/tests/test_shore.gd.uid new file mode 100644 index 0000000..f1af228 --- /dev/null +++ b/tests/test_shore.gd.uid @@ -0,0 +1 @@ +uid://cydvl5o72vanm diff --git a/tools/audio/make_audio.py b/tools/audio/make_audio.py index 458a04e..8fa51c2 100644 --- a/tools/audio/make_audio.py +++ b/tools/audio/make_audio.py @@ -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():