From 419c71e63849d68584cd5e9373d7303444906a8d Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sat, 7 Nov 2026 02:00:31 +0000 Subject: [PATCH] The Cove's wreck: driftwood and salt on the beach, the captain's sea chest (a treasure map, nails, salt; once each), each player's own X on the map; online too --- data/items.json | 20 +++++ data/worldlets/cove.json | 2 +- scripts/items/item_meshes.gd | 22 +++++ scripts/net/player_net.gd | 5 ++ scripts/net/world_net.gd | 12 +++ scripts/player/hands.gd | 4 + scripts/ui/map_screen.gd | 10 ++- scripts/world/props.gd | 15 +++- scripts/world/world.gd | 84 +++++++++++++++++ scripts/world/wreck.gd | 149 +++++++++++++++++++++++++++++++ scripts/world/wreck.gd.uid | 1 + scripts/world/wreck_chest.gd | 23 +++++ scripts/world/wreck_chest.gd.uid | 1 + tests/run_all.sh | 2 +- tests/test_net_lands.gd | 9 ++ tests/test_wreck.gd | 117 ++++++++++++++++++++++++ tests/test_wreck.gd.uid | 1 + 17 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 scripts/world/wreck.gd create mode 100644 scripts/world/wreck.gd.uid create mode 100644 scripts/world/wreck_chest.gd create mode 100644 scripts/world/wreck_chest.gd.uid create mode 100644 tests/test_wreck.gd create mode 100644 tests/test_wreck.gd.uid diff --git a/data/items.json b/data/items.json index 17fa4f3..e3e52e3 100644 --- a/data/items.json +++ b/data/items.json @@ -1073,6 +1073,26 @@ "description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.", "mesh": "salt" }, + { + "id": "driftwood", + "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.", + "mesh": "driftwood", + "fuel": 48.0 + }, + { + "id": "treasure_map", + "name": "Treasure Map", + "category": "tool", + "stack": 1, + "description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).", + "mesh": "treasure_map", + "read": { + "marks": "treasure" + } + }, { "id": "smoking_rack", "name": "Smoking Rack", diff --git a/data/worldlets/cove.json b/data/worldlets/cove.json index 4b70f45..f14c9bf 100644 --- a/data/worldlets/cove.json +++ b/data/worldlets/cove.json @@ -15,7 +15,7 @@ {"type": "ocean_edge"} ], "climate": {"snowline": 42.0}, - "places": [{"type": "den"}, {"type": "pier", "at": "beach"}], + "places": [{"type": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}], "next": [ {"to": "valley", "by": "tunnel", "where": "sea_cave"}, {"to": "highlands", "by": "sail", "where": "pier"} diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 926884d..ce45511 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -36,6 +36,8 @@ static func build(key: String) -> ArrayMesh: var m: ArrayMesh match key: "stick": m = _stick() + "driftwood": m = _driftwood() + "treasure_map": m = _treasure_map() "stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12)) "flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5) "wood": m = _wood() @@ -184,6 +186,26 @@ static func _stick() -> ArrayMesh: P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r) return P._commit(st) +## A sea-bleached branch, twisted, with a stub of a side branch. +static func _driftwood() -> ArrayMesh: + var st := P._begin() + var r := _rng(41) + const BLEACHED := Color(0.72, 0.68, 0.6) + const GREY := Color(0.58, 0.55, 0.5) + P._cylinder(st, Vector3(-0.5, 0.06, -0.04), Vector3(0.05, 0.08, 0.05), 0.07, 0.06, 6, BLEACHED, r, -1.0, GREY) + P._cylinder(st, Vector3(0.05, 0.08, 0.05), Vector3(0.48, 0.05, -0.02), 0.06, 0.035, 6, BLEACHED, r, -1.0, GREY) + 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 rolled-up, torn sheet of old parchment tied with string. +static func _treasure_map() -> ArrayMesh: + var st := P._begin() + var r := _rng(43) + const PARCH := Color(0.82, 0.74, 0.55) + P._cylinder(st, Vector3(-0.16, 0.04, 0), Vector3(0.16, 0.04, 0), 0.04, 0.04, 8, PARCH, r, -1.0, PARCH.darkened(0.15)) + P._cylinder(st, Vector3(-0.01, 0.04, 0), Vector3(0.01, 0.04, 0), 0.043, 0.043, 8, Color(0.45, 0.12, 0.08), r) + return P._commit(st) + static func _wood() -> ArrayMesh: var st := P._begin() var r := _rng(9) diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 53edef3..58557e1 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -363,6 +363,11 @@ func _run(p: Node3D, what: String, a: Array) -> void: "tunnel_prop": if w.tunnel != null: w.tunnel.set_prop(p, _i(a, 0)) + "wreck_chest": + # The wreck's sea chest: each player's share, once. + 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()) "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 339261a..d866aba 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -377,6 +377,18 @@ func tunnel_state(d: Dictionary) -> void: if Lands.hub: Lands.push_tunnel(int(d.get("dug", 0))) +## 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: + _all("_s2c_wreck", [d]) + mark_changed() + +@rpc("authority", "call_remote", "reliable") +func _s2c_wreck(d: Dictionary) -> void: + if not is_client() or world.wreck == null: + return + world.wreck.load_data(d) + @rpc("authority", "call_remote", "reliable") func _s2c_tunnel(d: Dictionary) -> void: if not is_client() or world.tunnel == null: diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 1e4a542..2ec98c9 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -174,6 +174,8 @@ func _physics_process(delta: float) -> void: _set_prompt("[RMB] Eat %s" % def.name) elif def.has("wear"): _set_prompt("[RMB] Wear %s" % def.name) + elif def.has("read"): + _set_prompt("[RMB] Read the %s" % String(def.name).to_lower()) elif def.has("bow"): var n: int = Actions.ammo_count(player, def.bow) _set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n) @@ -262,6 +264,8 @@ func _unhandled_input(event: InputEvent) -> void: Actions.eat(player, player.selected) elif d.has("wear"): Actions.wear(player, player.selected) + elif d.has("read"): + player.get_parent().read_map(player) # (the treasure map: your X, on your map) elif d.get("weapon", {}).has("throw"): _throw() elif event.is_action_pressed("rotate_piece"): diff --git a/scripts/ui/map_screen.gd b/scripts/ui/map_screen.gd index dc12ec5..8e46a52 100644 --- a/scripts/ui/map_screen.gd +++ b/scripts/ui/map_screen.gd @@ -23,6 +23,8 @@ const MARKS := { "build": [Color(0.62, 0.45, 0.28), "Your buildings"], "camp": [Color(1.00, 0.80, 0.35), "Camp"], "grave": [Color(0.85, 0.85, 0.85), "Your things"], + "wreck": [Color(0.55, 0.38, 0.22), "The wreck"], + "treasure": [Color(0.9, 0.12, 0.1), "X: your treasure"], } var world: Node3D @@ -132,10 +134,16 @@ func _draw_marks() -> void: for m in world.map_marks(): var at := _to_map(m.pos) var col: Color = MARKS.get(m.kind, [Color.WHITE])[0] + if m.kind == "treasure": + # An inked X, as on the old chart. + for d in [Vector2(1, 1), Vector2(1, -1)]: + _marks.draw_line(at - d * 8.0, at + d * 8.0, Color(0, 0, 0, 0.8), 5.0) + _marks.draw_line(at - d * 8.0, at + d * 8.0, col, 3.0) + continue var r := 3.0 if m.kind == "build" else 6.0 _marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col) _marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0) - if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]: + if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave", "wreck"]: _marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col) # Other players in this land: only on a co-op server (with PvP on, the # map never gives anyone away). Green dots with their names. diff --git a/scripts/world/props.gd b/scripts/world/props.gd index ed8f70b..5e7e4e0 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -664,9 +664,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} + "iron_nugget": 70, "clay": 60, "salt": 40, "driftwood": 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} + "iron_nugget": 0.25, "clay": 0.3, "salt": 0.25, "driftwood": 0.5} 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) @@ -709,10 +709,17 @@ func _scatter_pickups() -> void: var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h) chance = shore * 0.95 if by_lake else 0.0 "salt": - # Crusts on the cliff tops above the sea (P4). + # Crusts on the cliff tops above the sea (P4), or (the Cove) + # on the sand just above the waterline. chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0 + if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.2: + chance = 0.6 + "driftwood": + # 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 # 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"]: + if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt", "driftwood"]: chance += 0.5 if keep > chance: continue diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 733b131..93905cb 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -55,6 +55,7 @@ var _pieces: Node3D var hud: CanvasLayer 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 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 @@ -365,6 +366,15 @@ func _on_terrain_ready() -> void: props.pier = pr else: pr.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" + add_child(wk) + if not menu_mode and wk.place(self): + wreck = wk + props.keep_clear.append([wk.global_position, 9.0]) + else: + wk.free() # The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea # cave. On a server too (J3): the server decides the digging and the # crossings, and every player's game has the same rock to see and walk. @@ -690,6 +700,7 @@ func collect_save() -> Dictionary: "barrow": barrow.save_data() if barrow != null else {}, "stones": stones.save_data() if stones != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {}, + "wreck": wreck.to_data() if wreck != null else {}, "map": map_data(), } @@ -706,6 +717,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop "barrow": barrow.save_data() if barrow != null else {}, "stones": stones.save_data() if stones != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {}, + "wreck": wreck.to_data() if wreck != null else {}, } ## The live pieces (placed and not being removed). @@ -839,6 +851,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: stones.load_data(s.get("stones", {})) if tunnel != null: tunnel.load_data(s.get("tunnel", {})) + if wreck != null: + wreck.load_data(s.get("wreck", {})) if with_player: load_map_data(String(s.get("map", ""))) # The bear (bear_range.gd): applied when it's set up, after loading. @@ -971,6 +985,12 @@ func map_marks() -> Array: add.call("pier", (pier.start + pier.end) * 0.5, "The pier") if sea_cave != null: add.call("cave", sea_cave.global_position, "Sea cave") + if wreck != null: + add.call("wreck", wreck.global_position, "The wreck") + # Your X: shown while you carry the map (even where you haven't been). + var x := treasure_spot(my_id()) + if x != Vector3.INF and player.inventory.count("treasure_map") > 0: + out.append({"kind": "treasure", "pos": x, "label": "X"}) for piece in all_pieces(): var id: String = String(piece.get("item_id")) if piece.is_in_group("bed"): @@ -983,6 +1003,70 @@ func map_marks() -> Array: add.call("grave", piece.global_position, "Your things") return out +# --- the pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md §5) ---------------------- + +var _treasure_spots: Dictionary = {} ## player id -> Vector3 (worked out once) + +## Where player `id`'s treasure is buried (each player's map has its own X): +## a dry, gentle spot in the Cove's hills, picked from the world's seed and +## the player's id, clear of the water, the stream and the other places. +## INF where there's no treasure (no wreck in this land). +func treasure_spot(id: String) -> Vector3: + if wreck == null: + return Vector3.INF + if _treasure_spots.has(id): + return _treasure_spots[id] + var spots := treasure_spots() + var at: Vector3 = Vector3.INF if spots.is_empty() else spots[posmod(hash(id), spots.size())] + _treasure_spots[id] = at + return at + +## The world's hiding places for treasure: up to 12, at least 25 m apart, +## picked from its seed (so each player's X is one of these, and different +## players' are usually far apart). +var _hiding: Array = [] +func treasure_spots() -> Array: + if not _hiding.is_empty() or wreck == null: + return _hiding + var t: Node3D = terrain + var rng := RandomNumberGenerator.new() + rng.seed = t.noise_seed * 7919 + 2024 + var avoid: Array = [wreck.global_position, _spawn] + for n in [den, pier, sea_cave]: + if n != null and n.is_inside_tree(): + avoid.append(n.global_position if n != pier else (pier.start + pier.end) * 0.5) + for _try in range(3000): + if _hiding.size() >= 12: + break + var a := rng.randf() * TAU + var r: float = sqrt(rng.randf()) * t.play_radius * 0.85 + var x: float = cos(a) * r + var z: float = sin(a) * r + if not t.inside_boundary(x, z, 14.0) or t.beach_weight(x, z) > 0.3 or t.near_stream(x, z, 4.0): + continue + var h: float = t.sample_height(x, z) + if h < t.water_level + 2.0 or t.sample_normal(x, z).y < 0.92 or h > t.snow_height - 6.0: + continue + if avoid.any(func(q): return Vector2(q.x - x, q.z - z).length() < 22.0): + continue + if _hiding.any(func(q): return Vector2(q.x - x, q.z - z).length() < 25.0): + continue + _hiding.append(Vector3(x, h, z)) + return _hiding + +## The id of the player at this game (yours; a server's character's own). +func my_id() -> String: + return String(Net.get_identity().player_id) + +## Right-click with the treasure map: your X goes on your map, and it opens. +func read_map(p: Node3D) -> void: + if wreck == null: + Actions.notify.emit("The X is in the hills above the Cove: read it there") + return + Actions.notify.emit("X marks the spot, in the hills above the bay: it's on your map now (N). Take a pickaxe") + if hud != null and hud.map != null and not hud.map.is_open(): + hud.map.open() + ## In the cliff tunnel (J1): no daylight, no weather, sheltered. func underground_at(p: Vector3) -> bool: return tunnel != null and tunnel.contains(p) diff --git a/scripts/world/wreck.gd b/scripts/world/wreck.gd new file mode 100644 index 0000000..fc2fb86 --- /dev/null +++ b/scripts/world/wreck.gd @@ -0,0 +1,149 @@ +extends Node3D +## The Cove's wreck (docs/WORLDLETS_DESIGN.md §5, 0.6.0): an old ship driven +## up on the sand at the far end of the bay from the sea cave, her bow in the +## shallows, one side stove in. Inside, the captain's sea chest: a torn +## treasure map and a few useful things, **once for each player** (single- +## player and online alike: who's had theirs is saved with the Cove). +## +## Placed from the terrain alone, like the pier, so it's the same for +## everyone; only `opened` (who's had their share) is saved. + +const Pier = preload("res://scripts/world/pier.gd") +const OAK := Color(0.36, 0.25, 0.16) +const DARK := Color(0.24, 0.17, 0.11) +const WET := Color(0.2, 0.18, 0.15) +const IRON := Color(0.22, 0.22, 0.24) +## What's in the sea chest, for each player once. +const LOOT := {"treasure_map": 1, "nails": 4, "salt": 2} + +var world: Node3D +var opened: Array = [] ## the player ids who've had their share +var chest: StaticBody3D +var front := Vector3.ZERO ## where you stand to reach into the chest + +func place(w: Node3D) -> bool: + world = w + var t: Node3D = w.terrain + if not w.has_place("wreck") or not t._has_beach: + return false + # The far end of the beach from the sea cave, where the sand meets the water. + var a := deg_to_rad(t.beach_angle_deg - t.beach_half_width_deg * 0.8) + var dir := Vector2(cos(a), sin(a)) + var r: float = t._shore_d() - t._rim_offset(dir.x, dir.y) + var at2 := dir * (r - 1.0) + var at := Vector3(at2.x, maxf(t.sample_height(at2.x, at2.y), t.water_level - 0.3), at2.y) + # Bow out to sea (+Z local), a little askew, heeled over on her side. + var out := Vector3(dir.x, 0.0, dir.y).rotated(Vector3.UP, 0.35) + global_transform = Transform3D(Basis.looking_at(-out, Vector3.UP), at) + _build() + add_to_group("wreck") + return true + +func _build() -> void: + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var body := StaticBody3D.new() + body.name = "Hull" + add_child(body) + var heel := Basis(Vector3(0, 0, 1), deg_to_rad(-14.0)) + var parts: Array = [ + # [centre, size, colour] in the hull's frame (+Z the bow, +X starboard) + [Vector3(0, 0.12, 0), Vector3(0.35, 0.25, 9.0), DARK], # keel + [Vector3(-1.45, 0.75, 0), Vector3(0.14, 1.3, 8.2), OAK], # port side, whole + [Vector3(1.45, 0.75, 2.6), Vector3(0.14, 1.3, 3.0), OAK], # starboard, forward + [Vector3(1.45, 0.55, -3.0), Vector3(0.14, 0.9, 2.2), OAK], # starboard, aft (stove in between) + [Vector3(0, 0.75, -4.1), Vector3(2.9, 1.3, 0.14), OAK], # transom + [Vector3(0, 0.05, 0), Vector3(2.8, 0.1, 7.6), WET], # bilge boards + [Vector3(0, 1.8, 1.4), Vector3(0.22, 2.6, 0.22), DARK], # mast stump + ] + for p in parts: + var c: Vector3 = heel * p[0] + Pier._box(st, c, p[1], p[2]) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = p[1] + cs.shape = box + cs.transform = Transform3D(heel, c) + body.add_child(cs) + # Ribs over the open hull, some broken off short. + for i in range(6): + var z := -3.4 + i * 1.3 + for side in [-1.0, 1.0]: + if side > 0.0 and z > -1.8 and z < 1.0: + continue # (the stove-in side) + var h := 1.9 if (i + int(side)) % 3 != 0 else 1.2 + Pier._box(st, heel * Vector3(side * 1.3, h * 0.5 + 0.5, z), Vector3(0.12, h, 0.14), DARK) + # Planks strewn on the sand on the open side. + var rng := RandomNumberGenerator.new() + rng.seed = 919 + for i in range(5): + var off := Vector3(2.6 + rng.randf() * 2.5, 0.05, -2.0 + rng.randf() * 4.0) + Pier._box(st, off, Vector3(0.3, 0.06, 1.6 + rng.randf()), OAK.darkened(rng.randf() * 0.3)) + st.generate_normals() + var mi := MeshInstance3D.new() + mi.name = "Ship" + mi.mesh = st.commit() + var mat := StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.roughness = 0.95 + mi.material_override = mat + add_child(mi) + # The captain's sea chest, wedged inside by the break. + chest = StaticBody3D.new() + chest.name = "SeaChest" + chest.collision_layer = 1 | 2 + chest.set_script(preload("res://scripts/world/wreck_chest.gd")) + chest.wreck = self + var cst := SurfaceTool.new() + cst.begin(Mesh.PRIMITIVE_TRIANGLES) + Pier._box(cst, Vector3(0, 0.25, 0), Vector3(0.9, 0.5, 0.55), Color(0.3, 0.2, 0.12)) + Pier._box(cst, Vector3(0, 0.55, 0), Vector3(0.92, 0.12, 0.57), Color(0.27, 0.18, 0.1)) + for x in [-0.32, 0.32]: + Pier._box(cst, Vector3(x, 0.32, 0), Vector3(0.06, 0.64, 0.6), IRON) + cst.generate_normals() + var cmi := MeshInstance3D.new() + cmi.mesh = cst.commit() + cmi.material_override = mat + chest.add_child(cmi) + var ccs := CollisionShape3D.new() + var cb := BoxShape3D.new() + cb.size = Vector3(0.9, 0.62, 0.55) + ccs.shape = cb + ccs.position = Vector3(0, 0.31, 0) + chest.add_child(ccs) + add_child(chest) + chest.transform = Transform3D(heel * Basis(Vector3.UP, 0.4), heel * Vector3(0.5, 0.12, -0.4)) + front = global_transform * (heel * Vector3(2.4, 0.0, -0.4)) + front.y = world.terrain.sample_height(front.x, front.z) + +# --- the sea chest ------------------------------------------------------------- + +## Whether player `id` has had their share of the chest. +func had(id: String) -> bool: + return id in opened + +## Gives player `p` (id `id`) their share: the map and the rest, once. +## Returns false (and says why) when they've had it, or their bag's too full. +func open_for(p: Node, id: String) -> bool: + if had(id): + Actions.tell(p, "The sea chest's empty: you've had what was in it") + return false + for item in LOOT: + if p.inventory.space_for(item) < int(LOOT[item]): + Actions.tell(p, "Make room in your bag first") + return false + for item in LOOT: + p.inventory.add(item, int(LOOT[item])) + opened.append(id) + Actions.tell(p, "In the captain's sea chest: a torn treasure map, %d nails and %d salt. Right-click the map to read it" % [ + int(LOOT.nails), int(LOOT.salt)]) + Sfx.at("door_open", chest.global_position) + if Net.is_server(): + WorldNet.mark_changed() + return true + +func to_data() -> Dictionary: + return {"opened": opened.duplicate()} + +func load_data(d: Dictionary) -> void: + opened = (d.get("opened", []) as Array).duplicate() diff --git a/scripts/world/wreck.gd.uid b/scripts/world/wreck.gd.uid new file mode 100644 index 0000000..7b33102 --- /dev/null +++ b/scripts/world/wreck.gd.uid @@ -0,0 +1 @@ +uid://dydutxxckscny diff --git a/scripts/world/wreck_chest.gd b/scripts/world/wreck_chest.gd new file mode 100644 index 0000000..486d972 --- /dev/null +++ b/scripts/world/wreck_chest.gd @@ -0,0 +1,23 @@ +extends StaticBody3D +## The wreck's sea chest (wreck.gd): what you look at and press E on. Online, +## your game asks the server, which gives you your share once. + +var wreck: Node3D + +func interact_prompt(player: Node) -> String: + if wreck.had(_id(player)): + return "The captain's sea chest (empty: you've had what was in it)" + return "[E] Open the captain's sea chest" + +func interact(player: Node) -> void: + if wreck.world.is_online(): + PlayerNet.ask("wreck_chest", []) + return + wreck.open_for(player, _id(player)) + +## Whose share: the player's id (a server's character, or, at home, yours). +static func _id(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) diff --git a/scripts/world/wreck_chest.gd.uid b/scripts/world/wreck_chest.gd.uid new file mode 100644 index 0000000..b443fc6 --- /dev/null +++ b/scripts/world/wreck_chest.gd.uid @@ -0,0 +1 @@ +uid://oeh0v78qjmnh diff --git a/tests/run_all.sh b/tests/run_all.sh index 471baeb..29d31ca 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_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_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_net_lands.gd b/tests/test_net_lands.gd index e636559..cfdf6d6 100644 --- a/tests/test_net_lands.gd +++ b/tests/test_net_lands.gd @@ -148,6 +148,15 @@ func _body() -> void: check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)") var bob := await _bob({"do": "report"}) check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok")) + print("the wreck's sea chest, online") + await _tp(world.wreck.front + Vector3(0, 0.1, 0)) + _toasts.clear() + world.wreck.chest.interact(player) + await _until(func(): return player.inventory.count("treasure_map") == 1, 4.0) + check(player.inventory.count("treasure_map") == 1, "Alice's share: the server gives her a treasure map") + await _until(func(): return world.wreck.had(world.my_id()), 3.0) + check(world.wreck.chest.interact_prompt(player).contains("empty"), "and her game knows the chest's empty for her now") + check(world.map_marks().any(func(m): return m.kind == "treasure"), "her X is on her map") await _across_lands(bob_bed, bob_bed_at) print("back up to the Valley") diff --git a/tests/test_wreck.gd b/tests/test_wreck.gd new file mode 100644 index 0000000..c6ad6ec --- /dev/null +++ b/tests/test_wreck.gd @@ -0,0 +1,117 @@ +extends SceneTree +## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's wreck and the treasure map. +## The wreck is on the Cove's beach (and not in the Valley); driftwood and +## salt wash up on the sand; the captain's sea chest gives each player a +## treasure map, nails and salt, once; the map's X is that player's own (and +## somewhere else for the next player); reading the map tells you, and the X +## is on your N map while you carry it; who's had their share is saved. +## Run: godot --headless --path . --script res://tests/test_wreck.gd + +var _fails := 0 +var _finished := false +var S: Node +var A: 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") + A.notify.connect(func(t): _toasts.append(t)) + S.root_dir = "user://test_wreck_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _load() -> Node3D: + var w: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(w) + for _i in range(8): + await physics_frame + w.creatures.enabled = false + w.player.set_physics_process(false) + w.player.survival.set_process(false) + return w + +func _drop(w: Node3D) -> void: + w.queue_free() + for _i in range(3): + await process_frame + +func _body() -> void: + print("the Valley") + S.current_id = S.create_world("Valley", 1337) + var w: Node3D = await _load() + check(w.wreck == null, "no wreck in the Valley") + check(not w.props.pickups.values().any(func(p): return p.item_id == "driftwood"), "and no driftwood") + await _drop(w) + + print("the wreck") + S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"}) + w = await _load() + var t: Node3D = w.terrain + var wk: Node3D = w.wreck + check(wk != null and t.beach_weight(wk.global_position.x, wk.global_position.z) > 0.5, "a wreck on the Cove's beach") + if wk == null: + return + check(wk.global_position.distance_to(w.sea_cave.global_position) > 60.0, "at the far end of the bay from the sea cave (%.0f m)" % wk.global_position.distance_to(w.sea_cave.global_position)) + check(w.water_at(wk.front.x, wk.front.z).is_empty() or float(w.water_at(wk.front.x, wk.front.z).depth) < 0.6, "you can stand beside it to reach the chest") + var drift: Array = w.props.pickups.values().filter(func(p): return p.item_id == "driftwood") + check(drift.size() >= 20 and drift.all(func(p): return t.beach_weight(p.global_position.x, p.global_position.z) > 0.5), + "driftwood along the sand (%d)" % drift.size()) + check(w.props.pickups.values().any(func(p): return p.item_id == "salt"), "and salt crusts by the waterline") + + print("the captain's sea chest") + var p: Node3D = w.player + var me: String = w.my_id() + p.global_position = wk.front + check(wk.chest.interact_prompt(p).begins_with("[E] Open"), "[E] Open the captain's sea chest") + wk.chest.interact(p) + check(p.inventory.count("treasure_map") == 1 and p.inventory.count("nails") >= 4 and p.inventory.count("salt") >= 2, + "a torn treasure map, nails and salt") + check(_toasts.any(func(s): return String(s).begins_with("In the captain's sea chest")), "(and it says so)") + _toasts.clear() + wk.chest.interact(p) + check(p.inventory.count("treasure_map") == 1 and _toasts.has("The sea chest's empty: you've had what was in it"), "once: the second time it's empty") + check(wk.open_for(p, "someone-else"), "someone else gets their own share") + p.inventory.remove("treasure_map", 1) # (it went in this bag: theirs, not ours) + + print("the map") + var x: Vector3 = w.treasure_spot(me) + check(x != Vector3.INF and t.inside_boundary(x.x, x.z, 10.0) and x.y > t.water_level + 1.5, "your X: dry ground in the hills (%s)" % x) + var others := 0 + for i in range(8): + if w.treasure_spot("player-%d" % i).distance_to(x) > 20.0: + others += 1 + check(others >= 5, "and other players' X are mostly somewhere else (%d of 8)" % others) + check(w.treasure_spot(me) == x, "(the same every time for you)") + check(w.map_marks().any(func(m): return m.kind == "treasure" and m.pos == x), "on your map while you carry it, even where you haven't been") + _toasts.clear() + w.read_map(p) + check(_toasts.any(func(s): return String(s).begins_with("X marks the spot")), "reading it: X marks the spot") + p.inventory.remove("treasure_map", 1) + check(not w.map_marks().any(func(m): return m.kind == "treasure"), "(no map, no X)") + p.inventory.add("treasure_map", 1) + w.explore(wk.global_position) + check(w.map_marks().any(func(m): return m.kind == "wreck"), "the wreck's on the map once you've seen it") + + print("saved") + w.save_now() + await _drop(w) + w = await _load() + check(w.wreck.had(me) and w.wreck.had("someone-else") and not w.wreck.had("a-stranger"), "who's had their share is kept") + await _drop(w) + _finished = true diff --git a/tests/test_wreck.gd.uid b/tests/test_wreck.gd.uid new file mode 100644 index 0000000..52fb5bd --- /dev/null +++ b/tests/test_wreck.gd.uid @@ -0,0 +1 @@ +uid://chnwnd4xdcolj