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

This commit is contained in:
Paul Bailey
2026-11-07 11:16:15 +00:00
parent 9709d2c424
commit 2004fe193c
17 changed files with 579 additions and 5 deletions

View File

@@ -1111,10 +1111,50 @@
"name": "Driftwood", "name": "Driftwood",
"category": "resource", "category": "resource",
"stack": 30, "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", "mesh": "driftwood",
"fuel": 48.0 "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", "id": "treasure_map",
"name": "Treasure Map", "name": "Treasure Map",

View File

@@ -733,6 +733,40 @@
"stick": 2, "stick": 2,
"fiber": 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
}
} }
] ]
} }

View File

@@ -37,6 +37,11 @@ static func build(key: String) -> ArrayMesh:
match key: match key:
"stick": m = _stick() "stick": m = _stick()
"driftwood": m = _driftwood() "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() "treasure_map": m = _treasure_map()
"ship_compass": m = _ship_compass() "ship_compass": m = _ship_compass()
"spyglass": m = _spyglass() "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) 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) 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. ## A rolled-up, torn sheet of old parchment tied with string.
static func _treasure_map() -> ArrayMesh: static func _treasure_map() -> ArrayMesh:
var st := P._begin() var st := P._begin()

View File

@@ -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 != null and p.global_position.distance_to(w.wreck.chest.global_position) < 4.0:
if w.wreck.open_for(p, String(p.player_id)): if w.wreck.open_for(p, String(p.player_id)):
WorldNet.wreck_state(w.wreck.to_data()) 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": "tunnel_go":
# Down into the cliff tunnel, or back out at the top: a teleport # Down into the cliff tunnel, or back out at the top: a teleport
# the server makes (the tunnel's under the island), from nearby. # the server makes (the tunnel's under the island), from nearby.

View File

@@ -389,6 +389,17 @@ func _s2c_treasure(d: Dictionary) -> void:
return return
world.treasure.load_data(d) 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 ## Who's had their share of the wreck's sea chest changed: everyone here
## sees whether it's empty for them. ## sees whether it's empty for them.
func wreck_state(d: Dictionary) -> void: func wreck_state(d: Dictionary) -> void:

22
scripts/world/boat.gd Normal file
View File

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

View File

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

View File

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

View File

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

158
scripts/world/moorings.gd Normal file
View File

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

View File

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

View File

@@ -409,6 +409,8 @@ func _scatter_trees() -> void:
node.drops_final = {"wood": [2, 3], "stick": [2, 4]} node.drops_final = {"wood": [2, 3], "stick": [2, 4]}
node.max_hp = 3.0 node.max_hp = 3.0
node.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() var cs := CollisionShape3D.new()
cs.shape = cap cs.shape = cap
cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0) cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0)
@@ -664,9 +666,9 @@ func _scatter_pickups() -> void:
rng.seed = world_seed + 6 rng.seed = world_seed + 6
# Order matters: new kinds go at the END so existing saves keep their ids. # 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, 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, 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: for id: String in want:
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump. # 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) 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: # Washed up along a beach (the Cove), near the waterline:
# what a boat's hull is made of. # 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 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. # 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 chance += 0.5
if keep > chance: if keep > chance:
continue continue

View File

@@ -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 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 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 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 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 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 var _session_time := 0.0 ## seconds played since the last save
@@ -368,6 +369,15 @@ func _on_terrain_ready() -> void:
props.pier = pr props.pier = pr
else: else:
pr.free() 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. # 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() var wk := preload("res://scripts/world/wreck.gd").new()
wk.name = "Wreck" wk.name = "Wreck"
@@ -717,6 +727,7 @@ func collect_save() -> Dictionary:
"tunnel": tunnel.to_data() if tunnel != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {},
"wreck": wreck.to_data() if wreck != null else {}, "wreck": wreck.to_data() if wreck != null else {},
"treasure": treasure.to_data() if treasure != null else {}, "treasure": treasure.to_data() if treasure != null else {},
"moorings": moorings.to_data() if moorings != null else {},
"map": map_data(), "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 {}, "tunnel": tunnel.to_data() if tunnel != null else {},
"wreck": wreck.to_data() if wreck != null else {}, "wreck": wreck.to_data() if wreck != null else {},
"treasure": treasure.to_data() if treasure != 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). ## 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", {})) wreck.load_data(s.get("wreck", {}))
if treasure != null: if treasure != null:
treasure.load_data(s.get("treasure", {})) treasure.load_data(s.get("treasure", {}))
if moorings != null:
moorings.load_data(s.get("moorings", {}))
if with_player: if with_player:
load_map_data(String(s.get("map", ""))) load_map_data(String(s.get("map", "")))
# The bear (bear_range.gd): applied when it's set up, after loading. # The bear (bear_range.gd): applied when it's set up, after loading.

View File

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

160
tests/test_boat.gd Normal file
View File

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

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

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

View File

@@ -177,6 +177,20 @@ func _body() -> void:
"the server digs it up for her: the compass and the cutlass in her bag") "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) 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") 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) await _across_lands(bob_bed, bob_bed_at)
print("back up to the Valley") print("back up to the Valley")