From 2004fe193c88036cf501c956421ddb99c7e7e806 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sat, 7 Nov 2026 11:16:15 +0000 Subject: [PATCH] The rowing boat: seagrass at the tide line and resin from pines; rope, pitch, and a boat at the workbench (driftwood, rope, pitch, nails); tied up at a berth of its own at the end of the pier, one each, saved; Set sail needs the ship's compass and the Highlands aren't charted yet; online too --- data/items.json | 42 +++++++- data/recipes.json | 34 +++++++ scripts/items/item_meshes.gd | 82 +++++++++++++++ scripts/net/player_net.gd | 5 + scripts/net/world_net.gd | 11 ++ scripts/world/boat.gd | 22 ++++ scripts/world/boat.gd.uid | 1 + scripts/world/mooring_post.gd | 25 +++++ scripts/world/mooring_post.gd.uid | 1 + scripts/world/moorings.gd | 158 +++++++++++++++++++++++++++++ scripts/world/moorings.gd.uid | 1 + scripts/world/props.gd | 11 +- scripts/world/world.gd | 14 +++ tests/run_all.sh | 2 +- tests/test_boat.gd | 160 ++++++++++++++++++++++++++++++ tests/test_boat.gd.uid | 1 + tests/test_net_lands.gd | 14 +++ 17 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 scripts/world/boat.gd create mode 100644 scripts/world/boat.gd.uid create mode 100644 scripts/world/mooring_post.gd create mode 100644 scripts/world/mooring_post.gd.uid create mode 100644 scripts/world/moorings.gd create mode 100644 scripts/world/moorings.gd.uid create mode 100644 tests/test_boat.gd create mode 100644 tests/test_boat.gd.uid diff --git a/data/items.json b/data/items.json index 769f63b..955f69f 100644 --- a/data/items.json +++ b/data/items.json @@ -1111,10 +1111,50 @@ "name": "Driftwood", "category": "resource", "stack": 30, - "description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a boat will be built from.", + "description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.", "mesh": "driftwood", "fuel": 48.0 }, + { + "id": "seagrass", + "name": "Seagrass", + "category": "resource", + "stack": 50, + "description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.", + "mesh": "seagrass" + }, + { + "id": "rope", + "name": "Seagrass Rope", + "category": "resource", + "stack": 30, + "description": "Twisted from seagrass: for rigging and lashing a boat.", + "mesh": "rope" + }, + { + "id": "pine_resin", + "name": "Pine Resin", + "category": "resource", + "stack": 50, + "description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.", + "mesh": "pine_resin" + }, + { + "id": "pitch", + "name": "Pitch", + "category": "resource", + "stack": 30, + "description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.", + "mesh": "pitch" + }, + { + "id": "rowing_boat", + "name": "Rowing Boat", + "category": "tool", + "stack": 1, + "description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.", + "mesh": "rowing_boat" + }, { "id": "treasure_map", "name": "Treasure Map", diff --git a/data/recipes.json b/data/recipes.json index ec940d0..a608d9c 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -733,6 +733,40 @@ "stick": 2, "fiber": 2 } + }, + { + "id": "rope", + "output": "rope", + "count": 1, + "station": "", + "category": "tools", + "inputs": { + "seagrass": 3 + } + }, + { + "id": "pitch", + "output": "pitch", + "count": 1, + "station": "campfire", + "category": "building", + "inputs": { + "pine_resin": 2, + "salt": 1 + } + }, + { + "id": "rowing_boat", + "output": "rowing_boat", + "count": 1, + "station": "workbench", + "category": "building", + "inputs": { + "driftwood": 12, + "rope": 4, + "pitch": 3, + "nails": 6 + } } ] } diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 5e49c1d..96b8dc8 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -37,6 +37,11 @@ static func build(key: String) -> ArrayMesh: match key: "stick": m = _stick() "driftwood": m = _driftwood() + "seagrass": m = _seagrass() + "rope": m = _rope() + "pine_resin": m = _pine_resin() + "pitch": m = _pitch() + "rowing_boat": m = _rowing_boat() "treasure_map": m = _treasure_map() "ship_compass": m = _ship_compass() "spyglass": m = _spyglass() @@ -203,6 +208,83 @@ static func _driftwood() -> ArrayMesh: P._cylinder(st, Vector3(0.1, 0.09, 0.05), Vector3(0.22, 0.12, 0.24), 0.03, 0.015, 5, GREY, r) return P._commit(st) +## A tangle of long, flat ribbons of seagrass. +static func _seagrass() -> ArrayMesh: + var st := P._begin() + var r := _rng(45) + for i in range(6): + var a := r.randf() * TAU + var col := Color(0.28, 0.42, 0.2).lerp(Color(0.5, 0.48, 0.25), r.randf()) + _box(st, Vector3(r.randf_range(-0.03, 0.03), 0.01 + i * 0.006, r.randf_range(-0.03, 0.03)), Vector3(0.03, 0.006, 0.38), + col, Basis(Vector3.UP, a)) + return P._commit(st) + +## A coil of seagrass rope. +static func _rope() -> ArrayMesh: + var st := P._begin() + var r := _rng(47) + const ROPE := Color(0.6, 0.55, 0.36) + var n := 12 + for k in range(2): + for i in range(n): + var a0 := TAU * i / n + var a1 := TAU * (i + 1) / n + var rr := 0.1 - k * 0.035 + P._cylinder(st, Vector3(cos(a0) * rr, 0.025 + k * 0.03, sin(a0) * rr), Vector3(cos(a1) * rr, 0.025 + k * 0.03, sin(a1) * rr), + 0.02, 0.02, 5, ROPE.darkened(r.randf() * 0.12), r) + return P._commit(st) + +## A few lumps of sticky amber resin. +static func _pine_resin() -> ArrayMesh: + var st := P._begin() + var r := _rng(49) + var n := FastNoiseLite.new() + n.seed = 49 + n.frequency = 3.0 + for i in range(4): + var c := Vector3(r.randf_range(-0.05, 0.05), 0.03, r.randf_range(-0.05, 0.05)) + P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.045), n, 0.25, Color(0.8, 0.5, 0.12).darkened(r.randf() * 0.25), r, 1) + return P._commit(st) + +## A small clay pot of black pitch, a stick stood in it. +static func _pitch() -> ArrayMesh: + var st := P._begin() + var r := _rng(51) + P._cylinder(st, Vector3(0, 0, 0), Vector3(0, 0.12, 0), 0.07, 0.085, 8, Color(0.55, 0.36, 0.24), r, 0.0) + P._cylinder(st, Vector3(0, 0.115, 0), Vector3(0, 0.125, 0), 0.078, 0.078, 8, Color(0.06, 0.05, 0.05), r, -1.0, Color(0.06, 0.05, 0.05)) + P._cylinder(st, Vector3(0.01, 0.06, 0.0), Vector3(0.05, 0.24, 0.02), 0.008, 0.007, 4, HANDLE, r) + return P._commit(st) + +## A rowing boat, bow to -Z: a model 0.55 m long (moorings.gd shows it eight +## times over at the pier). Driftwood planks, pitch-black seams, two thwarts +## and a pair of oars. +static func _rowing_boat() -> ArrayMesh: + var st := P._begin() + var r := _rng(53) + const WOOD := Color(0.66, 0.6, 0.5) + const SEAM := Color(0.1, 0.08, 0.07) + const OAR := Color(0.55, 0.42, 0.28) + var hw := 0.085 # half the beam amidships + # Bottom: a wide plank aft, narrowing to the bow. + _box(st, Vector3(0, 0.01, 0.07), Vector3(hw * 1.7, 0.012, 0.36), WOOD) + _box(st, Vector3(0, 0.014, -0.16), Vector3(hw * 1.1, 0.012, 0.12), WOOD) + for side in [-1.0, 1.0]: + # Sides amidships and aft, two strakes with a dark seam between. + _box(st, Vector3(side * hw, 0.03, 0.06), Vector3(0.008, 0.03, 0.38), WOOD.darkened(0.05)) + _box(st, Vector3(side * hw, 0.047, 0.06), Vector3(0.0085, 0.004, 0.38), SEAM) + _box(st, Vector3(side * hw, 0.062, 0.06), Vector3(0.008, 0.028, 0.38), WOOD) + # Bow: the sides swept in to meet at the stem. + var bow := Basis(Vector3.UP, side * 0.53) + _box(st, Vector3(side * hw * 0.5, 0.048, -0.193), Vector3(0.008, 0.07, 0.17), WOOD, bow) + _box(st, Vector3(0, 0.055, -0.265), Vector3(0.014, 0.085, 0.014), WOOD.darkened(0.15)) # stem + _box(st, Vector3(0, 0.045, 0.25), Vector3(hw * 2.0, 0.06, 0.008), WOOD.darkened(0.08)) # transom + for z in [-0.02, 0.15]: + _box(st, Vector3(0, 0.055, z), Vector3(hw * 2.0, 0.008, 0.035), WOOD.darkened(0.12)) # thwarts + for side in [-1.0, 1.0]: + P._cylinder(st, Vector3(side * 0.04, 0.07, 0.17), Vector3(side * 0.05, 0.075, -0.13), 0.004, 0.004, 4, OAR, r) + _box(st, Vector3(side * 0.05, 0.075, -0.15), Vector3(0.022, 0.003, 0.05), OAR) + return P._commit(st) + ## A rolled-up, torn sheet of old parchment tied with string. static func _treasure_map() -> ArrayMesh: var st := P._begin() diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index d5c2e2e..05ace68 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -372,6 +372,11 @@ func _run(p: Node3D, what: String, a: Array) -> void: if w.wreck != null and p.global_position.distance_to(w.wreck.chest.global_position) < 4.0: if w.wreck.open_for(p, String(p.player_id)): WorldNet.wreck_state(w.wreck.to_data()) + "moor": + # The mooring post at the end of the pier: tie up your boat. + if w.moorings != null and p.global_position.distance_to(w.moorings.post.global_position) < 4.0: + if w.moorings.moor(p, String(p.player_id)): + WorldNet.boats_state(w.moorings.to_data()) "tunnel_go": # Down into the cliff tunnel, or back out at the top: a teleport # the server makes (the tunnel's under the island), from nearby. diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index d926464..0e0d167 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -389,6 +389,17 @@ func _s2c_treasure(d: Dictionary) -> void: return world.treasure.load_data(d) +## Someone tied up a boat at the pier: everyone here sees it. +func boats_state(d: Dictionary) -> void: + _all("_s2c_boats", [d]) + mark_changed() + +@rpc("authority", "call_remote", "reliable") +func _s2c_boats(d: Dictionary) -> void: + if not is_client() or world.moorings == null: + return + world.moorings.load_data(d) + ## Who's had their share of the wreck's sea chest changed: everyone here ## sees whether it's empty for them. func wreck_state(d: Dictionary) -> void: diff --git a/scripts/world/boat.gd b/scripts/world/boat.gd new file mode 100644 index 0000000..cebaefe --- /dev/null +++ b/scripts/world/boat.gd @@ -0,0 +1,22 @@ +extends StaticBody3D +## A rowing boat tied up at the pier (moorings.gd): whose it is, and, for +## its owner, "Set sail". + +const PostScript = preload("res://scripts/world/mooring_post.gd") + +var moorings: Node3D +var owner_id := "" +var rest := Transform3D() ## where it's tied up (it bobs about this) + +func mine(player: Node) -> bool: + return PostScript.id_of(player) == owner_id + +func interact_prompt(player: Node) -> String: + if mine(player): + return "Your rowing boat [E] Set sail" + var who := String(moorings.moored.get(owner_id, {}).get("name", "")) + return "%s's rowing boat" % who if not who.is_empty() else "Someone's rowing boat" + +func interact(player: Node) -> void: + if mine(player): + moorings.set_sail(player) diff --git a/scripts/world/boat.gd.uid b/scripts/world/boat.gd.uid new file mode 100644 index 0000000..2285a24 --- /dev/null +++ b/scripts/world/boat.gd.uid @@ -0,0 +1 @@ +uid://bd8yx310myk8m diff --git a/scripts/world/mooring_post.gd b/scripts/world/mooring_post.gd new file mode 100644 index 0000000..d352465 --- /dev/null +++ b/scripts/world/mooring_post.gd @@ -0,0 +1,25 @@ +extends StaticBody3D +## The mooring post at the end of the pier (moorings.gd): E ties up the +## rowing boat in your bag. Online, your game asks the server. + +var moorings: Node3D + +## Whose boat: the player's id (a server's character, or, at home, yours). +static func id_of(player: Node) -> String: + var pid: Variant = player.get("player_id") + if pid != null and not String(pid).is_empty(): + return String(pid) + return String(Net.get_identity().player_id) + +func interact_prompt(player: Node) -> String: + if moorings.has_boat(id_of(player)): + return "Mooring post (your boat's tied up here)" + if player.inventory.count(moorings.BOAT_ITEM) > 0: + return "Mooring post [E] Tie up your boat" + return "Mooring post (build a rowing boat at a workbench, and tie it up here)" + +func interact(player: Node) -> void: + if moorings.world.is_online(): + PlayerNet.ask("moor", []) + return + moorings.moor(player, id_of(player)) diff --git a/scripts/world/mooring_post.gd.uid b/scripts/world/mooring_post.gd.uid new file mode 100644 index 0000000..057f414 --- /dev/null +++ b/scripts/world/mooring_post.gd.uid @@ -0,0 +1 @@ +uid://dt0os4fg56rxi diff --git a/scripts/world/moorings.gd b/scripts/world/moorings.gd new file mode 100644 index 0000000..3adcf24 --- /dev/null +++ b/scripts/world/moorings.gd @@ -0,0 +1,158 @@ +extends Node3D +## The Cove's moorings (0.6.0, docs/WORLDLETS_DESIGN.md §5): berths along +## the end of the pier, and a mooring post on the deck. Build a rowing boat at +## a workbench, carry it down to the pier and press E at the post: it's tied +## up at a berth of its own, one boat for each player, and it stays there +## (saved with the Cove; online the server decides, and everyone sees +## everyone's boat). Your own boat's prompt is "Set sail", but nobody's +## charted the way to the Highlands yet: that's the next part of the journey, +## and the ship's compass from the pirate chest is what it'll need. + +const ItemMeshes = preload("res://scripts/items/item_meshes.gd") +const BoatScript = preload("res://scripts/world/boat.gd") +const PostScript = preload("res://scripts/world/mooring_post.gd") +const BERTHS := 6 ## three each side (any further in, it shallows) +const BOAT_SCALE := 8.0 ## the item's model (0.55 m) up to a boat (4.4 m) +const BERTH_GAP := 5.0 +const BOAT_ITEM := "rowing_boat" + +var world: Node3D +var pier: Node3D +var moored: Dictionary = {} ## player id -> {"berth": i, "name": who} +var boats: Dictionary = {} ## player id -> boat.gd +var post: StaticBody3D +var _t := 0.0 + +func setup(w: Node3D) -> bool: + world = w + pier = w.pier + if pier == null: + return false + add_to_group("moorings") + post = StaticBody3D.new() + post.name = "MooringPost" + post.collision_layer = 1 | 2 + post.set_script(PostScript) + post.moorings = self + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + pier._box(st, Vector3(0, 0.45, 0), Vector3(0.24, 0.9, 0.24), pier.POST) + pier._box(st, Vector3(0, 0.8, 0), Vector3(0.32, 0.08, 0.32), pier.POST.darkened(0.2)) + pier._box(st, Vector3(0, 0.62, 0), Vector3(0.27, 0.1, 0.27), Color(0.62, 0.55, 0.38)) # (a coil of rope) + st.generate_normals() + var mi := MeshInstance3D.new() + mi.mesh = st.commit() + var mat := StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.roughness = 0.9 + mi.material_override = mat + post.add_child(mi) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(0.3, 0.9, 0.3) + cs.shape = box + cs.position = Vector3(0, 0.45, 0) + post.add_child(cs) + add_child(post) + post.global_position = pier.end - pier.dir * 1.2 + _right() * (pier.WIDTH * 0.5 - 0.3) + return true + +func _right() -> Vector3: + return pier.dir.cross(Vector3.UP).normalized() + +## Where berth `i` is: alongside the end of the pier, in pairs, bow out to sea. +func berth_xf(i: int) -> Transform3D: + var side := 1.0 if i % 2 == 0 else -1.0 + var along: Vector3 = pier.end - pier.dir * (2.0 + (i / 2) * BERTH_GAP) + var p: Vector3 = along + _right() * side * (pier.WIDTH * 0.5 + 1.0) + p.y = world.terrain.sea_level - 0.08 + return Transform3D(Basis.looking_at(pier.dir, Vector3.UP), p) + +func has_boat(id: String) -> bool: + return moored.has(id) + +func _free_berth() -> int: + var used := moored.values().map(func(m): return int(m.berth)) + for i in range(BERTHS): + if not i in used: + return i + return -1 + +## Player `p` (id `id`) ties up the boat in their bag. Says why not and +## returns false when they can't. +func moor(p: Node, id: String) -> bool: + if has_boat(id): + Actions.tell(p, "Your boat's already tied up here") + return false + if p.inventory.count(BOAT_ITEM) <= 0: + Actions.tell(p, "Build a rowing boat at a workbench and bring it here to tie it up") + return false + var b := _free_berth() + if b < 0: + Actions.tell(p, "Every berth along the pier is taken") + return false + p.inventory.remove(BOAT_ITEM, 1) + var who := String(p.get("display")) if p.get("display") != null else "" + moored[id] = {"berth": b, "name": who} + _sync() + Actions.tell(p, "You tie your boat up at the end of the pier") + Sfx.at("creak", post.global_position) + if Net.is_server(): + WorldNet.mark_changed() + return true + +## Your boat: off to the Highlands. Not yet. +func set_sail(p: Node) -> void: + if p.inventory.count("ship_compass") <= 0: + Actions.tell(p, "Past the bay it's open sea: without a ship's compass you'd be lost within the hour") + return + Actions.tell(p, "The compass needle points north over the water, but nobody's charted the way to the Highlands yet. (That's the next part of the journey: coming in a later version.)") + +func _sync() -> void: + for id in boats.keys(): + if not moored.has(id): + boats[id].queue_free() + boats.erase(id) + for id in moored: + if boats.has(id): + continue + var b: StaticBody3D = StaticBody3D.new() + b.name = "Boat_%d" % int(moored[id].berth) + b.collision_layer = 1 | 2 + b.set_script(BoatScript) + b.moorings = self + b.owner_id = String(id) + var mi := MeshInstance3D.new() + mi.name = "Hull" + mi.mesh = ItemMeshes.build(BOAT_ITEM) + mi.material_override = ItemDB.item_material() + mi.scale = Vector3.ONE * BOAT_SCALE + b.add_child(mi) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(1.4, 0.6, 4.2) + cs.shape = box + cs.position = Vector3(0, 0.3, 0) + b.add_child(cs) + add_child(b) + b.global_transform = berth_xf(int(moored[id].berth)) + b.rest = b.global_transform + boats[id] = b + +## A gentle bob on the swell (just the look). +func _process(delta: float) -> void: + _t += delta + for id in boats: + var b: Node3D = boats[id] + var k := float(int(moored[id].berth)) * 1.7 + b.global_transform = b.rest.translated(Vector3(0, sin(_t * 1.1 + k) * 0.05, 0)) * Transform3D(Basis(Vector3(0, 0, 1), sin(_t * 0.8 + k) * 0.03), Vector3.ZERO) + +func to_data() -> Dictionary: + return {"moored": moored.duplicate(true)} + +func load_data(d: Dictionary) -> void: + moored = {} + var m: Dictionary = d.get("moored", {}) + for id in m: + moored[String(id)] = {"berth": int(m[id].get("berth", 0)), "name": String(m[id].get("name", ""))} + _sync() diff --git a/scripts/world/moorings.gd.uid b/scripts/world/moorings.gd.uid new file mode 100644 index 0000000..57a9f65 --- /dev/null +++ b/scripts/world/moorings.gd.uid @@ -0,0 +1 @@ +uid://dlc5hki2qd50v diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 5e7e4e0..e6a0276 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -409,6 +409,8 @@ func _scatter_trees() -> void: node.drops_final = {"wood": [2, 3], "stick": [2, 4]} node.max_hp = 3.0 node.hp = 3.0 + elif vi in conifers: + node.drops_final["pine_resin"] = [1, 2] # (0.6.0: pitch for a boat) var cs := CollisionShape3D.new() cs.shape = cap cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0) @@ -664,9 +666,9 @@ func _scatter_pickups() -> void: rng.seed = world_seed + 6 # Order matters: new kinds go at the END so existing saves keep their ids. var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150, - "iron_nugget": 70, "clay": 60, "salt": 40, "driftwood": 45} + "iron_nugget": 70, "clay": 60, "salt": 40, "driftwood": 45, "seagrass": 40} var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45, - "iron_nugget": 0.25, "clay": 0.3, "salt": 0.25, "driftwood": 0.5} + "iron_nugget": 0.25, "clay": 0.3, "salt": 0.25, "driftwood": 0.5, "seagrass": 0.4} for id: String in want: # Fibre grows as a plant (wild flax) you pick, 2 fibre a clump. var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id) @@ -718,8 +720,11 @@ func _scatter_pickups() -> void: # Washed up along a beach (the Cove), near the waterline: # what a boat's hull is made of. chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.6 else 0.0 + "seagrass": + # (0.6.0) Washed up at the tide line, for a boat's rope. + chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 0.8 else 0.0 # A few right by the spawn so the first craft is quick. - if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt", "driftwood"]: + if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt", "driftwood", "seagrass"]: chance += 0.5 if keep > chance: continue diff --git a/scripts/world/world.gd b/scripts/world/world.gd index fedf49c..cc6f163 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -57,6 +57,7 @@ var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null var wreck: Node3D ## the Cove's wreck (scripts/world/wreck.gd, 0.6.0), or null var treasure: Node3D ## the pirate treasure (scripts/world/treasure.gd), or null +var moorings: Node3D ## the boats tied up at the pier (scripts/world/moorings.gd), or null var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null var traveling := false ## on the way to another worldlet (the scene is about to change) var _session_time := 0.0 ## seconds played since the last save @@ -368,6 +369,15 @@ func _on_terrain_ready() -> void: props.pier = pr else: pr.free() + # The moorings (0.6.0): rowing boats tied up at the end of the pier. + if pier != null and not menu_mode: + var mo := preload("res://scripts/world/moorings.gd").new() + mo.name = "Moorings" + add_child(mo) + if mo.setup(self): + moorings = mo + else: + mo.free() # The wreck (0.6.0): on the Cove's beach, with the sea chest's treasure maps. var wk := preload("res://scripts/world/wreck.gd").new() wk.name = "Wreck" @@ -717,6 +727,7 @@ func collect_save() -> Dictionary: "tunnel": tunnel.to_data() if tunnel != null else {}, "wreck": wreck.to_data() if wreck != null else {}, "treasure": treasure.to_data() if treasure != null else {}, + "moorings": moorings.to_data() if moorings != null else {}, "map": map_data(), } @@ -735,6 +746,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop "tunnel": tunnel.to_data() if tunnel != null else {}, "wreck": wreck.to_data() if wreck != null else {}, "treasure": treasure.to_data() if treasure != null else {}, + "moorings": moorings.to_data() if moorings != null else {}, } ## The live pieces (placed and not being removed). @@ -872,6 +884,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: wreck.load_data(s.get("wreck", {})) if treasure != null: treasure.load_data(s.get("treasure", {})) + if moorings != null: + moorings.load_data(s.get("moorings", {})) if with_player: load_map_data(String(s.get("map", ""))) # The bear (bear_range.gd): applied when it's set up, after loading. diff --git a/tests/run_all.sh b/tests/run_all.sh index c50dd71..eb08934 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_shore 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_boat 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_boat.gd b/tests/test_boat.gd new file mode 100644 index 0000000..b703fda --- /dev/null +++ b/tests/test_boat.gd @@ -0,0 +1,160 @@ +extends SceneTree +## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the rowing boat. Seagrass washes up +## at the Cove's tide line and pine trees give resin when felled; seagrass +## twists into rope, resin and salt boil into pitch at a campfire, and +## driftwood, rope, pitch and nails make a rowing boat at a workbench. The +## mooring post at the end of the pier ties it up at a berth of its own, one +## for each player, and it's saved there. "Set sail" needs the ship's +## compass, and even then the Highlands aren't charted yet. No pier, no +## moorings: none in the Valley. +## Run: godot --headless --path . --script res://tests/test_boat.gd + +var _fails := 0 +var _finished := false +var S: Node +var A: Node +var DB: Node +var _toasts: Array[String] = [] + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + 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_boat_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 + x.creatures.enabled = false + x.player.set_physics_process(false) + x.player.survival.set_process(false) + return x + +func _drop(x: Node3D) -> void: + x.queue_free() + for _i in range(3): + await process_frame + +func _toast_has(s: String) -> bool: + return _toasts.any(func(t): return String(t).contains(s)) + +func _body() -> void: + print("the Valley") + S.current_id = S.create_world("Valley", 1337) + var w: Node3D = await _load() + check(w.moorings == null, "no pier, no moorings") + var resin: Array = w.props.resource_nodes.values().filter(func(n): return n.drops_final.has("pine_resin")) + check(resin.size() > 20, "its pines give resin when felled (%d trees)" % resin.size()) + await _drop(w) + + print("the Cove") + var cove: String = S.create_world("Cove", 1337, {"worldlet": "cove"}) + S.current_id = cove + w = await _load() + var t: Node3D = w.terrain + var mo: Node3D = w.moorings + check(mo != null, "moorings at the pier") + if mo == null: + return + check(mo.post.global_position.distance_to(w.pier.end) < 2.0, "a mooring post at the end of the pier") + var deep := true + var depths: Array = [] + for i in range(mo.BERTHS): + var b: Transform3D = mo.berth_xf(i) + var wa: Dictionary = w.water_at(b.origin.x, b.origin.z) + depths.append("%.1f" % float(wa.get("depth", -1.0))) + deep = deep and not wa.is_empty() and float(wa.depth) > 0.6 and not w.pier.covers(b.origin.x, b.origin.z, 0.2) + check(deep, "%d berths alongside it, all in open water (%s m deep)" % [mo.BERTHS, ", ".join(depths)]) + var grass: Array = w.props.pickups.values().filter(func(p): return p.item_id == "seagrass") + check(grass.size() >= 15 and grass.all(func(p): return t.on_sand(p.global_position.x, p.global_position.z)), + "seagrass at the tide line (%d)" % grass.size()) + + print("building it") + var p: Node3D = w.player + p.global_position = w.build.debug_find_site(w._spawn, 3.0) + var inv = p.inventory + inv.add("seagrass", 12) + inv.add("pine_resin", 6) + inv.add("salt", 3) + inv.add("driftwood", 12) + inv.add("nails", 6) + var ok := true + for i in range(4): + ok = A.craft(p, "rope") and ok + check(ok and inv.count("rope") == 4 and inv.count("seagrass") == 0, "3 seagrass twist into a rope, by hand") + check(not A.craft(p, "pitch"), "pitch needs a fire") + var fire: Node3D = w.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), p.global_position + Vector3(2, 0, 0))) + for i in range(3): + ok = A.craft(p, "pitch") and ok + check(ok and inv.count("pitch") == 3 and inv.count("salt") == 0, "2 resin and a salt boil into pitch at a campfire") + check(not A.craft(p, "rowing_boat"), "the boat needs a workbench") + w.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), p.global_position + Vector3(-2, 0, 0))) + check(A.craft(p, "rowing_boat") and inv.count("rowing_boat") == 1, "driftwood, rope, pitch and nails: a rowing boat") + check(inv.count("driftwood") == 0 and inv.count("rope") == 0 and inv.count("pitch") == 0 and inv.count("nails") == 0, "(all used)") + w.remove_piece(fire) + + print("tying it up") + p.global_position = mo.post.global_position + w.pier.dir * -1.0 + var me: String = mo.post.id_of(p) + check(mo.post.interact_prompt(p).contains("[E] Tie up your boat"), "the post: [E] Tie up your boat") + _toasts.clear() + mo.post.interact(p) + check(mo.has_boat(me) and inv.count("rowing_boat") == 0, "it's tied up, out of your bag") + check(_toast_has("tie your boat up"), "(and it says so)") + var boat: Node3D = mo.boats.get(me) + check(boat != null and boat.global_position.distance_to(mo.berth_xf(int(mo.moored[me].berth)).origin) < 0.3, "at a berth by the pier") + check(boat != null and boat.interact_prompt(p) == "Your rowing boat [E] Set sail", "\"Your rowing boat [E] Set sail\"") + check(mo.post.interact_prompt(p).contains("your boat's tied up"), "the post knows") + inv.add("rowing_boat", 1) + _toasts.clear() + check(not mo.moor(p, me) and inv.count("rowing_boat") == 1, "one boat each: a second doesn't go in") + inv.remove("rowing_boat", 1) + + print("setting sail") + _toasts.clear() + boat.interact(p) + check(_toast_has("without a ship's compass"), "without a ship's compass you'd be lost (%s)" % [_toasts]) + inv.add("ship_compass", 1) + _toasts.clear() + boat.interact(p) + check(_toast_has("nobody's charted the way to the Highlands"), "with it: the Highlands aren't charted yet") + check(mo.has_boat(me), "(and it stays tied up)") + + print("someone else's") + inv.add("rowing_boat", 1) + check(mo.moor(p, "someone-else"), "someone else ties up theirs") + check(mo.boats.size() == 2 and int(mo.moored["someone-else"].berth) != int(mo.moored[me].berth), "at a berth of its own") + check(mo.boats["someone-else"].interact_prompt(p) == "Someone's rowing boat", "not yours: \"Someone's rowing boat\"") + _toasts.clear() + mo.boats["someone-else"].interact(p) + check(_toasts.is_empty(), "and you can't sail it") + + print("saved") + var state: Dictionary = w.collect_save() + S.save_state(cove, state, 1.0) + await _drop(w) + w = await _load() + mo = w.moorings + check(mo != null and mo.has_boat(me) and mo.has_boat("someone-else") and mo.boats.size() == 2, "both boats are still there after a reload") + await _drop(w) + _finished = true diff --git a/tests/test_boat.gd.uid b/tests/test_boat.gd.uid new file mode 100644 index 0000000..c676e16 --- /dev/null +++ b/tests/test_boat.gd.uid @@ -0,0 +1 @@ +uid://sbe5ddhndij5 diff --git a/tests/test_net_lands.gd b/tests/test_net_lands.gd index 327fd28..7cdb4b4 100644 --- a/tests/test_net_lands.gd +++ b/tests/test_net_lands.gd @@ -177,6 +177,20 @@ func _body() -> void: "the server digs it up for her: the compass and the cutlass in her bag") await _until(func(): return world.my_id() in world.treasure.dug, 3.0) check(world.my_id() in world.treasure.dug and not world.treasure.mounds.has(world.my_id()), "and her mound's gone") + print("tying up a boat, online") + PN.ask("debug_give", ["rowing_boat", 1]) + await _until(func(): return player.inventory.count("rowing_boat") == 1, 3.0) + var mo: Node3D = world.moorings + var was: Vector3 = player.global_position + await _tp(mo.post.global_position - world.pier.dir * 1.0 + Vector3(0, 0.2, 0)) + check(mo.post.interact_prompt(player).contains("Tie up your boat"), "at the mooring post with her boat") + mo.post.interact(player) + await _until(func(): return mo.has_boat(world.my_id()), 4.0) + check(mo.has_boat(world.my_id()) and mo.boats.has(world.my_id()), "the server ties it up, and her game shows it at its berth") + await _until(func(): return player.inventory.count("rowing_boat") == 0, 3.0) + check(player.inventory.count("rowing_boat") == 0, "(out of her bag)") + check(mo.boats[world.my_id()].interact_prompt(player).contains("Set sail"), "it's hers: [E] Set sail") + await _tp(was + Vector3(0, 0.2, 0)) # (back up the beach, where there's room for a hut) await _across_lands(bob_bed, bob_bed_at) print("back up to the Valley")