From 980ee535794ed32f81cea5b5a322f49b6131e467 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 6 Nov 2026 17:51:58 +0000 Subject: [PATCH] J1: the cliff tunnel (seam, six propped stretches, dark, lights), travel Valley <-> Cove through the sea cave, a save file per worldlet; New World has no Where (--dev-worldlets to try others) --- docs/WORLDLETS_DESIGN.md | 40 ++- scripts/core/actions.gd | 8 + scripts/core/saves.gd | 74 ++++- scripts/player/survival.gd | 5 +- scripts/ui/title_screen.gd | 21 +- scripts/world/day_night.gd | 22 ++ scripts/world/props.gd | 4 + scripts/world/sea_cave.gd | 120 ++++++++ scripts/world/sea_cave.gd.uid | 1 + scripts/world/sea_cave_spot.gd | 10 + scripts/world/sea_cave_spot.gd.uid | 1 + scripts/world/tunnel.gd | 451 +++++++++++++++++++++++++++++ scripts/world/tunnel.gd.uid | 1 + scripts/world/tunnel_face.gd | 44 +++ scripts/world/tunnel_face.gd.uid | 1 + scripts/world/tunnel_spot.gd | 35 +++ scripts/world/tunnel_spot.gd.uid | 1 + scripts/world/world.gd | 101 ++++++- tests/run_all.sh | 2 +- tests/test_tunnel.gd | 259 +++++++++++++++++ tests/test_tunnel.gd.uid | 1 + tests/test_worldlet_pick.gd | 12 +- 22 files changed, 1181 insertions(+), 33 deletions(-) create mode 100644 scripts/world/sea_cave.gd create mode 100644 scripts/world/sea_cave.gd.uid create mode 100644 scripts/world/sea_cave_spot.gd create mode 100644 scripts/world/sea_cave_spot.gd.uid create mode 100644 scripts/world/tunnel.gd create mode 100644 scripts/world/tunnel.gd.uid create mode 100644 scripts/world/tunnel_face.gd create mode 100644 scripts/world/tunnel_face.gd.uid create mode 100644 scripts/world/tunnel_spot.gd create mode 100644 scripts/world/tunnel_spot.gd.uid create mode 100644 tests/test_tunnel.gd create mode 100644 tests/test_tunnel.gd.uid diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index e069a3f..7d1e1c0 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -1,7 +1,34 @@ # Worldlets design -Status: **W0-W3 built** (the Cove, without its stream yet); the rest is -planned. The journey between +Status: **W0-W3 and J1 built** (the Cove without its stream yet; the +tunnel single-player only); the rest is planned. + +*As built in J1:* +- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip, + 7 m in from the edge, its angle from the seed) and the tunnel space, out + of sight 260 m under the island: a CSG rock block with the landings and + six sloping stretches cut out (6 m on, 4 m down each, switching back on + two lanes: 24 m down), then a sea cave. Faces (`tunnel_face.gd`) answer + like boulders, so the pick works on them: tier 3 only ("Too hard: you + need a better pick"), 10 blows for the seam and 15 per stretch. Each + stretch must be propped (2 wood, [E] at its mouth) before the next face + takes a blow ("It's loose..."): 10 wood all the way down. +- Underground: no sun, moon, sky reflections or sky-tinted fog + (`day_night.underground`), no weather, no lake water, sheltered. Candles + and torch stands place anywhere on its floor and save as ordinary pieces. +- Walking out of the sea cave's mouth travels (`world.travel()`): the + worldlet you leave is saved, your character and the clock go with you, + and the other loads, arriving at its sea cave (`sea_cave.gd`, on the + Cove's beach where the headland starts). [E] at the cave goes back up the + same tunnel to its bottom, once it's dug through. +- Saves: `world.json` is the home worldlet's state, as before; + others are `worldlets/.json`; meta.json's `worldlet` is where you + are, `home` where you began, `arrive` where the next load puts you. +- `tests/test_tunnel.gd`: dig through, walk every stretch down and back + up, light it, reload, out to the Cove, back up, out at the top, and the + chest at home is still there. +- Not yet: Old Tam's tip about the seam, the Guide page, and travel on a + server (J3: the tunnel isn't built online). The journey between worldlets is §10, and the two ways to play (the journey, or one land) are in its decisions. @@ -17,9 +44,10 @@ its decisions. - Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire, salted, or smoked, like the lake's. -- Running from the source, the New World and Host pages also offer - worldlets that aren't starts, marked "(testing)", so the Cove can be - played before the tunnel exists. Release builds offer only the Valley. +- New World has no "Where": the journey always begins in the Valley (the + Host page keeps it, for One land mode). Started with + `godot --path . -- --dev-worldlets`, New World and Host offer every + worldlet, the others marked "(testing)", to try one directly. - No sea stacks without a sea cliff. - Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod only in deep water), and the Cove in `test_terrain_edges` and @@ -302,7 +330,7 @@ tunnel goes **down**, inside the cliff, from the clifftop to the shore. - **Props:** each new stretch needs a **timber prop** (2 wood) set at its mouth before you can dig the next. Leave one out and the face won't take another blow ("It's loose: prop it first"); nothing caves in on you, so - it's a cost, not a trap. That's about 12 wood all the way down. + it's a cost, not a trap. That's 10 wood all the way down. - **Light:** the tunnel has no daylight at all. Every stretch is pitch dark until you bring light: a torch in your hand works, but your hands are full with the pick, so the way to do it is to set **candles and torch stands** diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 0a0da2a..bbc5caa 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -257,6 +257,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: return false var held: Dictionary = ItemDB.get_item(player.held_item_id()) var tool: Dictionary = held.get("tool", {}) + if node.has_method("refusal"): # (the cliff tunnel's faces: their own rules first) + var why: String = node.refusal() + if not why.is_empty(): + notify.emit(why) + return false + if tool.get("kind", "") == node.tool_kind and int(tool.get("tier", 0)) < node.min_tier: + notify.emit("Too hard: you need a better pick") + return false if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier: notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind)) return false diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd index 55b5ff1..9a4ddd9 100644 --- a/scripts/core/saves.gd +++ b/scripts/core/saves.gd @@ -60,6 +60,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St } for k in options: meta[k] = options[k] + meta["home"] = String(meta.worldlet) _write_json(_dir(id).path_join("meta.json"), meta) return id @@ -84,6 +85,8 @@ func delete_world(id: String) -> void: var d := DirAccess.open(dir) if d == null: return + for sub in d.get_directories(): # (worldlets/, and a server's backups/) + _remove_tree(dir.path_join(sub)) for f in d.get_files(): d.remove(f) DirAccess.remove_absolute(dir) @@ -93,16 +96,64 @@ func delete_world(id: String) -> void: func read_meta(id: String) -> Dictionary: return _read_json(_dir(id).path_join("meta.json")) -func load_state(id: String) -> Dictionary: - var s := _read_json(_dir(id).path_join("world.json")) - if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")): - push_warning("Saves: world.json unreadable, using backup") - s = _read_json(_dir(id).path_join("world.json.bak")) +# --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) -------------------- +# A world can span several worldlets (the journey). Each keeps its own state +# file: the one it began in (its "home") is world.json, as before, so older +# saves are unchanged; the others are worldlets/.json. meta.json's +# "worldlet" is where you are now, so that's the one loaded. + +## The worldlet world `id` is being played in now. +func worldlet_of(id: String) -> String: + return String(read_meta(id).get("worldlet", "valley")) + +## The worldlet world `id` began in (its world.json). +func home_of(id: String) -> String: + var m := read_meta(id) + return String(m.get("home", m.get("worldlet", "valley"))) + +## The state file for worldlet `w` of world `id`. +func state_path(id: String, w: String) -> String: + if w == home_of(id): + return _dir(id).path_join("world.json") + return _dir(id).path_join("worldlets/%s.json" % w) + +## Whether world `id` has been to worldlet `w` (has a state for it). +func has_state(id: String, w: String) -> bool: + return FileAccess.file_exists(state_path(id, w)) + +## Moves world `id` to worldlet `to` (the next load is there), arriving at +## `arrive` (a place the arriving world puts you: "sea_cave", "tunnel_bottom"). +func set_worldlet(id: String, to: String, arrive: String) -> void: + var m := read_meta(id) + if not m.has("home"): + m["home"] = String(m.get("worldlet", "valley")) # (pin it before "worldlet" moves) + m["worldlet"] = to + m["arrive"] = arrive + _write_json(_dir(id).path_join("meta.json"), m) + +## Where the next load should put you, once (then forgotten). +func take_arrival(id: String) -> String: + var m := read_meta(id) + var a := String(m.get("arrive", "")) + if not a.is_empty(): + m.erase("arrive") + _write_json(_dir(id).path_join("meta.json"), m) + return a + +## The state of the worldlet being played (or of worldlet `w`). +func load_state(id: String, w: String = "") -> Dictionary: + var path := state_path(id, worldlet_of(id) if w.is_empty() else w) + var s := _read_json(path) + if s.is_empty() and FileAccess.file_exists(path + ".bak"): + push_warning("Saves: %s unreadable, using backup" % path.get_file()) + s = _read_json(path + ".bak") return s -## Writes the state for `id` and updates its meta. Returns true on success. -func save_state(id: String, state: Dictionary, session_seconds: float) -> bool: - var path := _dir(id).path_join("world.json") +## Writes the state for `id` (the worldlet being played, or `w`) and +## updates its meta. Returns true on success. +func save_state(id: String, state: Dictionary, session_seconds: float, w: String = "") -> bool: + var path := state_path(id, worldlet_of(id) if w.is_empty() else w) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) if FileAccess.file_exists(path): DirAccess.copy_absolute(path, path + ".bak") state["version"] = VERSION @@ -129,3 +180,10 @@ func _read_json(path: String) -> Dictionary: return {} var data = JSON.parse_string(FileAccess.get_file_as_string(path)) return data if typeof(data) == TYPE_DICTIONARY else {} + +static func _remove_tree(d: String) -> void: + for sub in DirAccess.get_directories_at(d): + _remove_tree(d.path_join(sub)) + for f in DirAccess.get_files_at(d): + DirAccess.remove_absolute(d.path_join(f)) + DirAccess.remove_absolute(d) diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 4b558b5..b832910 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -103,8 +103,9 @@ func step(dt: float) -> void: _shelter_left -= dt if _shelter_left <= 0.0: _shelter_left = 0.25 - roofed = world.build.roofed_at(p) - sheltered = roofed and world.build.enclosure_at(p) >= 0.74 + var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round) + roofed = cave or world.build.roofed_at(p) + sheltered = roofed and (cave or world.build.enclosure_at(p) >= 0.74) stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5 cozy = sheltered and _rug_near(p) # Rain soaks you unless there's a roof over your head. diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index 5f01241..a83c12c 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -594,7 +594,10 @@ func _new_page() -> Control: _rot_pick.add_item(t[1]) _rot_pick.custom_minimum_size = Vector2(360, 36) v.add_child(_rot_pick) - _worldlet_pick = _worldlet_picker(v) + # The journey always begins in the Valley, so a new world has no "Where" + # (only with --dev-worldlets, to try the others). + if dev_worldlets(): + _worldlet_pick = _worldlet_picker(v) var h := HBoxContainer.new() h.add_theme_constant_override("separation", 10) h.add_child(_button("Create & Play", _create, 170)) @@ -698,7 +701,7 @@ var _host_worldlet: OptionButton ## description under it. (Only the Valley today: the others are reached on ## the journey, so the list is short and the picker sits still.) func _worldlet_picker(v: VBoxContainer) -> OptionButton: - var starts: Array = offered_worldlets(OS.is_debug_build()) + var starts: Array = offered_worldlets(dev_worldlets()) v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM)) var pick := OptionButton.new() pick.custom_minimum_size = Vector2(360, 36) @@ -716,12 +719,16 @@ func _worldlet_picker(v: VBoxContainer) -> OptionButton: about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", ""))) return pick -## The worldlets offered for a new world: the starts, and (running from the -## source, `debug`) the rest too, marked for testing, so a worldlet can be -## tried before the journey reaches it. A release build offers only the starts. -static func offered_worldlets(debug: bool) -> Array: +## Started with `-- --dev-worldlets`: every worldlet can be picked, for +## trying one before the journey reaches it. +static func dev_worldlets() -> bool: + return "--dev-worldlets" in OS.get_cmdline_user_args() + +## The worldlets offered for a new world or island: the starts, and (`dev`) +## the rest too, marked for testing. +static func offered_worldlets(dev: bool) -> Array: var out: Array = Terrain.starting_worldlets() - if debug: + if dev: for w in Terrain.worldlets(): if not w.start: var t: Dictionary = w.duplicate() diff --git a/scripts/world/day_night.gd b/scripts/world/day_night.gd index 52efeeb..dfc2a7f 100644 --- a/scripts/world/day_night.gd +++ b/scripts/world/day_night.gd @@ -15,6 +15,8 @@ const DAY_BIAS := 0.2 var day := 1 var time := 8.0 / 24.0 var frozen := false ## title-screen backdrop: hold a fixed time +var underground := 0.0 ## 0..1: how far into the tunnel you are (no daylight; world.gd sets it) +const AERIAL := 0.55 ## the fog's sky tint with distance (scenes/default_env.tres) var sun: DirectionalLight3D var moon: DirectionalLight3D @@ -123,6 +125,26 @@ func apply(force: bool) -> void: env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0 env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0 env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast) + if underground > 0.0: + # In the tunnel (J1): no sun, moon or sky, only the lights you bring. + var u := underground + sun.light_energy *= 1.0 - u + sun.visible = sun.light_energy > 0.01 + sun.shadow_enabled = sun.visible + moon.light_energy *= 1.0 - u + moon.visible = moon.light_energy > 0.01 + env.ambient_light_energy = lerpf(env.ambient_light_energy, 0.015, u) + env.ambient_light_sky_contribution = 1.0 - u + env.fog_light_energy = lerpf(env.fog_light_energy, 0.0, u) + env.volumetric_fog_density *= 1.0 - u + # (Sky reflections and the fog's sky-coloured distance would light the + # rock blue-grey all the way down: off.) + env.fog_aerial_perspective = lerpf(AERIAL, 0.0, u) + env.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED if u > 0.5 else Environment.REFLECTION_SOURCE_SKY + else: + env.ambient_light_sky_contribution = 1.0 + env.fog_aerial_perspective = AERIAL + env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY env.fog_density = BASE_FOG * w_fog _sky_timer -= get_process_delta_time() diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 4352441..b39ac67 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -32,6 +32,7 @@ const FLOWER_COLORS := [ var terrain: Node3D var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world) +var keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave var _player: Node3D var _spawn := Vector3.ZERO var _forest_noise := FastNoiseLite.new() @@ -422,6 +423,9 @@ func _scatter_trees() -> void: func _on_clifftop(x: float, z: float) -> bool: if pier != null and pier.covers(x, z, 2.0): return true + for c in keep_clear: + if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]): + return true return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ and terrain.inside_boundary(x, z, -8.0) diff --git a/scripts/world/sea_cave.gd b/scripts/world/sea_cave.gd new file mode 100644 index 0000000..2df520d --- /dev/null +++ b/scripts/world/sea_cave.gd @@ -0,0 +1,120 @@ +extends Node3D +## The Cove's sea cave (J1): the bottom of the Valley's cliff tunnel, seen +## from the shore. A dark opening in the rocks at the end of the beach, +## where the headland meets the sand. [E] goes in and up the tunnel, back to +## the Valley, once that tunnel has been dug through from the top. (Until +## then the cave runs back into solid rock.) +## +## Placed from the terrain alone, like the pier, so it's the same for +## everyone; nothing about it is saved here (the tunnel is the Valley's). + +var world: Node3D +var to := "valley" +var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel) + +## Places the cave if this worldlet has a way back by tunnel from a sea cave. +func place(w: Node3D) -> bool: + world = w + var t: Node3D = w.terrain + var way: Dictionary = {} + for c in w.crossings(): + if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cave": + way = c + if way.is_empty() or not t._has_beach: + return false + to = String(way.to) + # At the end of the beach, where the headland starts: the first dry, + # gentle spot coming in from the waterline. + var a := deg_to_rad(t.beach_angle_deg + t.beach_half_width_deg + t.beach_fade_deg * 0.35) + var dir := Vector2(cos(a), sin(a)) + var at := Vector3.INF + for i in range(60): + var r: float = t._shore_d() - 2.0 - i * 0.5 - t._rim_offset(dir.x, dir.y) + var p := dir * r + var h: float = t.sample_height(p.x, p.y) + if h > t.water_level + 0.5 and t.sample_normal(p.x, p.y).y > 0.86 and t.inside_boundary(p.x, p.y, 3.0): + at = Vector3(p.x, h, p.y) + break + if at == Vector3.INF: + return false + # Facing back along the beach (towards its middle), so you see it from the sand. + var mid: Vector2 = t.shore_point(-6.0) + var face := Vector3(mid.x - at.x, 0.0, mid.y - at.z).normalized() + global_transform = Transform3D(Basis.looking_at(face, Vector3.UP), at) + front = at + face * 2.4 + front.y = t.sample_height(front.x, front.z) + _build(w) + add_to_group("sea_cave") + return true + +## Whether the Valley's tunnel has been dug right down to here. +func open() -> bool: + if not Saves.has_world(): + return false + var s: Dictionary = Saves.load_state(Saves.current_id, to) + return int(s.get("tunnel", {}).get("dug", 0)) >= preload("res://scripts/world/tunnel.gd").STRETCHES + +func interact_prompt(_player: Node) -> String: + if open(): + return "[E] Go into the sea cave and up the tunnel to the %s" % preload("res://scripts/world/terrain.gd").worldlet_name(to) + return "A sea cave. It runs back into solid rock: the way up hasn't been dug" + +func interact(_player: Node) -> void: + if open(): + world.travel(to, "tunnel_bottom") + else: + Actions.notify.emit(interact_prompt(null)) + +func _build(w: Node3D) -> void: + var rock := StandardMaterial3D.new() + rock.albedo_color = Color(0.42, 0.40, 0.38) + rock.roughness = 1.0 + if w.terrain.noise_tex != null: + rock.albedo_texture = w.terrain.noise_tex + rock.uv1_triplanar = true + rock.uv1_scale = Vector3(0.18, 0.18, 0.18) + var body := StaticBody3D.new() + add_child(body) + # (local -Z faces the beach; the rocks are behind, +Z) + for part in [[Vector3(-2.0, 1.6, 1.0), Vector3(1.8, 3.6, 3.0)], [Vector3(2.0, 1.4, 1.0), Vector3(1.8, 3.2, 3.0)], + [Vector3(0, 3.5, 1.0), Vector3(5.8, 1.4, 3.2)], [Vector3(0, 1.4, 2.4), Vector3(2.4, 3.0, 0.6)], + [Vector3(-3.4, 1.0, 2.0), Vector3(2.0, 2.4, 3.0)], [Vector3(3.2, 0.8, 2.2), Vector3(1.8, 2.0, 2.6)]]: + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = part[1] + mi.mesh = bm + mi.material_override = rock + mi.position = part[0] + mi.rotation.y = part[0].x * 0.06 + add_child(mi) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = part[1] + cs.shape = box + cs.position = part[0] + cs.rotation.y = part[0].x * 0.06 + body.add_child(cs) + var dark := MeshInstance3D.new() + var q := QuadMesh.new() + q.size = Vector2(2.2, 2.8) + dark.mesh = q + var m := StandardMaterial3D.new() + m.albedo_color = Color(0.02, 0.02, 0.025) + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + dark.material_override = m + dark.position = Vector3(0, 1.4, 2.08) + dark.rotation.y = PI + add_child(dark) + # What you press E on: the opening (interactables layer: you walk into it). + var spot := StaticBody3D.new() + spot.collision_layer = 2 + spot.collision_mask = 0 + var cs2 := CollisionShape3D.new() + var b2 := BoxShape3D.new() + b2.size = Vector3(2.2, 2.8, 1.6) + cs2.shape = b2 + spot.add_child(cs2) + spot.position = Vector3(0, 1.4, 1.2) + spot.set_script(preload("res://scripts/world/sea_cave_spot.gd")) + spot.cave = self + add_child(spot) diff --git a/scripts/world/sea_cave.gd.uid b/scripts/world/sea_cave.gd.uid new file mode 100644 index 0000000..0db4218 --- /dev/null +++ b/scripts/world/sea_cave.gd.uid @@ -0,0 +1 @@ +uid://dne5yll2h1st2 diff --git a/scripts/world/sea_cave_spot.gd b/scripts/world/sea_cave_spot.gd new file mode 100644 index 0000000..20f839c --- /dev/null +++ b/scripts/world/sea_cave_spot.gd @@ -0,0 +1,10 @@ +extends StaticBody3D +## The sea cave's opening (sea_cave.gd): what you look at and press E on. + +var cave: Node3D + +func interact_prompt(player: Node) -> String: + return cave.interact_prompt(player) + +func interact(player: Node) -> void: + cave.interact(player) diff --git a/scripts/world/sea_cave_spot.gd.uid b/scripts/world/sea_cave_spot.gd.uid new file mode 100644 index 0000000..69c95b6 --- /dev/null +++ b/scripts/world/sea_cave_spot.gd.uid @@ -0,0 +1 @@ +uid://cvc1bri24m1h4 diff --git a/scripts/world/tunnel.gd b/scripts/world/tunnel.gd new file mode 100644 index 0000000..3656c30 --- /dev/null +++ b/scripts/world/tunnel.gd @@ -0,0 +1,451 @@ +extends Node3D +## The cliff tunnel (J1, docs/WORLDLETS_DESIGN.md §10): the Valley's way down +## to the Cove, dug by hand. +## +## - **The seam:** a cracked outcrop on the clifftop strip near the edge. +## Only the antler pickaxe (tier 3) bites into it; once it gives, it's a +## dark opening: [E] goes down into the tunnel. +## - **The tunnel:** its own small space, out of sight beneath the island +## (the heightmap can't have holes), so going in and out is a step through +## a doorway. Six sloping stretches, each one turning back under the last +## like a stair, about 24 m down in all. The end of the dug part is a rock +## face; each stretch dug must be propped (2 wood, [E] at its mouth) before +## the next face takes a blow. Nothing caves in: it's a cost, not a trap. +## - **Dark:** no daylight reaches it (day_night.underground). Bring light: +## candles and torch stands can be set anywhere on its floor, and stay. +## - **The bottom:** the last stretch breaks into a sea cave; walking out of +## its mouth is the crossing to the Cove (world.travel()). +## +## How far it's dug, the face's damage and the props are saved with the +## Valley (to_data()); the lights are ordinary placed pieces. Single-player +## for now: on a server it isn't built (J3 brings travel online). + +const TunnelFace = preload("res://scripts/world/tunnel_face.gd") + +const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island) +const STRETCHES := 6 +const RUN := 6.0 ## each stretch: this far along... +const DROP := 4.0 ## ...and this far down +const W := 2.6 ## passage width +const H := 3.0 ## passage height +const LAND := 3.4 ## landing length, where it turns back +const LAND_EXT := 2.0 ## how far a landing's headroom reaches into its stretches +const GAP := 1.4 ## rock between the two lanes +const CAVE_LEN := 7.0 ## the sea cave passage at the bottom +const PROP_WOOD := 2 +const FACE_HP := 45.0 ## 15 blows of the antler pickaxe +const SEAM_HP := 30.0 + +var world: Node3D +var terrain: Node3D +var to := "cove" ## the worldlet at the bottom + +# Saved state. +var opened := false ## the seam has given way +var dug := 0 ## stretches dug (0..STRETCHES) +var face_hp := FACE_HP ## damage to the face being dug (the seam's, before it opens) +var props: Array = [] ## props[k] for stretch k (1-based; [0] unused) + +# Where things are (world coordinates). +var seam_at := Vector3.ZERO ## the seam, on the ground +var seam_out := Vector3.ZERO ## where you stand outside it (inland) +var top := Vector3.ZERO ## inside, at the top +var bottom := Vector3.ZERO ## inside, at the bottom by the cave +var cave_mouth := Vector3.ZERO ## the far end of the sea cave +var aabb := AABB() + +var _rock_mat: StandardMaterial3D +var _face_mat: StandardMaterial3D +var _wood_mat: StandardMaterial3D +var _rock: CSGCombiner3D +var _cuts: Node3D +var _face: StaticBody3D +var _seam: Node3D +var _spots: Node3D + +## Builds the tunnel for `w` if its worldlet has a way on by tunnel from +## its sea cliff. Returns false (and builds nothing) otherwise. +func setup(w: Node3D) -> bool: + world = w + terrain = w.terrain + var way: Dictionary = {} + for c in w.crossings(): + if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cliff": + way = c + if way.is_empty() or not terrain._has_sea: + return false + to = String(way.to) + props.resize(STRETCHES + 1) + props.fill(false) + _make_materials() + _place_seam() + _layout() + _rebuild() + add_to_group("tunnel") + return true + +# --- where it is ---------------------------------------------------------------- + +func _lane_z(k: int) -> float: + return 0.0 if k % 2 == 1 else W + GAP + +func _landing_x(k: int) -> float: + return (k % 2) * (LAND + RUN) + +func _floor_y(k: int) -> float: + return -k * DROP + +## Inside the tunnel's space (with a margin), in world coordinates. +func contains(p: Vector3) -> bool: + return aabb.grow(1.0).has_point(p) + +func _layout() -> void: + var x0 := -LAND * 0.5 - 3.0 - CAVE_LEN + var x1 := LAND * 1.5 + RUN + 3.0 + var y0 := _floor_y(STRETCHES) - 3.0 + var y1 := H + 3.0 + var z0 := -W * 0.5 - 3.0 + var z1 := W * 1.5 + GAP + 3.0 + aabb = AABB(ORIGIN + Vector3(x0, y0, z0), Vector3(x1 - x0, y1 - y0, z1 - z0)) + top = ORIGIN + Vector3(_landing_x(0) - LAND * 0.25, 0.0, _lane_z(1)) + var zb := _lane_z(STRETCHES) + bottom = ORIGIN + Vector3(_landing_x(STRETCHES), _floor_y(STRETCHES), zb) + cave_mouth = bottom + Vector3(-LAND * 0.5 - CAVE_LEN + 0.6, 0.0, 0.0) + +## The seam: on the clifftop strip, a few metres in from the edge, at an +## angle the seed picks. +func _place_seam() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = terrain.noise_seed + 404 + for _try in range(40): + var a: float = terrain.sea_angle_deg + rng.randf_range(-18.0, 18.0) + var p: Vector2 = terrain.coast_point(a, -7.0) + if not terrain.inside_boundary(p.x, p.y, 2.0): + continue + var n: Vector3 = terrain.sample_normal(p.x, p.y) + if n.y < 0.9: + continue + seam_at = Vector3(p.x, terrain.sample_height(p.x, p.y), p.y) + break + var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized() + seam_out = seam_at + inland * 2.2 + seam_out.y = terrain.sample_height(seam_out.x, seam_out.z) + +# --- digging --------------------------------------------------------------------- + +## Why the face at `stage` won't take a blow ("" = it will). +func dig_refusal(stage: int) -> String: + if stage >= 2: + for k in range(1, stage): + if not props[k]: + return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD + return "" + +func face_hit(f: Node) -> void: + face_hp = f.hp + +func face_broken(f: Node) -> void: + face_hp = FACE_HP + if f.stage == 0: + opened = true + Actions.notify.emit("The seam gives way: a dark passage leads down into the cliff") + else: + dug = f.stage + if dug >= STRETCHES: + Actions.notify.emit("You break through into a sea cave. You can hear the waves") + else: + Actions.notify.emit("The rock gives. Prop this stretch before you dig on") + _rebuild.call_deferred() + +## [E] at the mouth of stretch `k`: sets a timber prop there. +func set_prop(player: Node, k: int) -> bool: + if k < 1 or k > dug or props[k]: + return false + if player.inventory.count("wood") < PROP_WOOD: + Actions.notify.emit("A timber prop needs %d wood" % PROP_WOOD) + return false + player.inventory.remove("wood", PROP_WOOD) + props[k] = true + Actions.notify.emit("Propped. The next face is safe to dig") + _rebuild.call_deferred() + return true + +# --- going in and out ---------------------------------------------------------- + +func go_in(player: Node3D) -> void: + _move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0)) + +func go_out(player: Node3D) -> void: + _move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized()) + +func _move(player: Node3D, at: Vector3, facing: Vector3) -> void: + player.global_position = at + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0) + +# --- saving ---------------------------------------------------------------------- + +func to_data() -> Dictionary: + var p: Array = [] + for k in range(1, STRETCHES + 1): + if props[k]: + p.append(k) + return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p} + +func load_data(d: Dictionary) -> void: + opened = bool(d.get("opened", false)) + dug = clampi(int(d.get("dug", 0)), 0, STRETCHES) + face_hp = float(d.get("face_hp", FACE_HP)) + props.fill(false) + for k in d.get("props", []): + if int(k) >= 1 and int(k) <= STRETCHES: + props[int(k)] = true + _rebuild() + +# --- building it --------------------------------------------------------------- + +func _make_materials() -> void: + _rock_mat = StandardMaterial3D.new() + _rock_mat.albedo_color = Color(0.40, 0.38, 0.36) + _rock_mat.roughness = 1.0 + if terrain.noise_tex != null: + _rock_mat.albedo_texture = terrain.noise_tex + _rock_mat.uv1_triplanar = true + _rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18) + _face_mat = _rock_mat.duplicate() + _face_mat.albedo_color = Color(0.30, 0.27, 0.25) + _wood_mat = StandardMaterial3D.new() + _wood_mat.albedo_color = Color(0.42, 0.29, 0.17) + _wood_mat.roughness = 0.9 + +func _rebuild() -> void: + for n in [_rock, _seam, _spots, _face]: + if n != null and is_instance_valid(n): + n.queue_free() + _face = null + _build_rock() + _build_seam() + _spots = Node3D.new() + _spots.name = "Spots" + _spots.top_level = true + add_child(_spots) + # The face being dug: at the mouth of the next stretch (or the seam). + if opened and dug < STRETCHES: + var k := dug + 1 + var mouth := _mouth(k) + _face = _make_face(k, mouth + Vector3(0, H * 0.5, 0), Vector3(0.5, H, W + 0.2)) + # Props set (posts and a beam), and spots to set the missing ones. + for k in range(1, dug + 1): + var mouth := _mouth(k) + if props[k]: + _add_prop_mesh(mouth) + else: + _add_spot(mouth + Vector3(0, H * 0.5, 0), Vector3(0.6, H, W), "prop", k) + _add_spot(ORIGIN + Vector3(_landing_x(0) - LAND * 0.5 + 0.35, H * 0.5, _lane_z(1)), Vector3(0.4, H, W), "out", 0) + if dug >= STRETCHES: + _add_mouth() + +## The start of stretch k, on the floor of landing k-1 (world coordinates). +func _mouth(k: int) -> Vector3: + var s := signf(_landing_x(k) - _landing_x(k - 1)) + return ORIGIN + Vector3(_landing_x(k - 1) + s * LAND * 0.5, _floor_y(k - 1), _lane_z(k)) + +func _build_rock() -> void: + _rock = CSGCombiner3D.new() + _rock.name = "Rock" + _rock.use_collision = true + _rock.collision_layer = 1 + _rock.top_level = true + add_child(_rock) + _rock.global_position = ORIGIN + var base := CSGBox3D.new() + base.size = aabb.size + base.position = aabb.get_center() - ORIGIN + base.material = _rock_mat + _rock.add_child(base) + if not opened: + return + # Landings: the top one on lane A only; the rest span both lanes. + for k in range(0, dug + 1): + var x := _landing_x(k) + var y := _floor_y(k) + var zlo := -W * 0.5 if k < STRETCHES else _lane_z(k) - W * 0.5 + var zhi := W * 0.5 if k == 0 else W * 1.5 + GAP + if k == STRETCHES: + zhi = _lane_z(k) + W * 0.5 + _cut(Vector3(x, y + H * 0.5, (zlo + zhi) * 0.5), Vector3(LAND, H, zhi - zlo), 0.0) + # Headroom LAND_EXT on into the stretch going down from here (in its + # lane only): a sloping cut's end is square to the slope, so on its + # own it would leave a lip of rock across the top of it at head height. + if k < dug: + var side := signf(_landing_x(k + 1) - x) + _cut(Vector3(x + side * (LAND + LAND_EXT) * 0.5, y + H * 0.5, _lane_z(k + 1)), Vector3(LAND_EXT, H, W), 0.0) + # The stretches dug: sloping boxes from one landing down to the next. + for k in range(1, dug + 1): + var a := _mouth(k) - ORIGIN + var s := signf(_landing_x(k) - _landing_x(k - 1)) + var b := Vector3(_landing_x(k) - s * LAND * 0.5, _floor_y(k), _lane_z(k)) + var d := b - a + var length := Vector2(d.x, d.y).length() + var ang := atan2(d.y, d.x) + var up := Vector3(-sin(ang), cos(ang), 0.0) + if up.y < 0.0: + up = -up + _cut((a + b) * 0.5 + up * H * 0.5, Vector3(length + 0.3, H, W), ang) + # The sea cave, off the bottom landing. + if dug >= STRETCHES: + var c := bottom - ORIGIN + Vector3(-LAND * 0.5 - CAVE_LEN * 0.5, 0.0, 0.0) + _cut(c + Vector3(0, H * 0.6, 0), Vector3(CAVE_LEN + 0.2, H * 1.2, W + 0.8), 0.0) + +func _cut(center: Vector3, size: Vector3, z_rot: float) -> void: + var b := CSGBox3D.new() + b.operation = CSGShape3D.OPERATION_SUBTRACTION + b.size = size + b.position = center + b.rotation = Vector3(0.0, 0.0, z_rot) + b.material = _rock_mat + _rock.add_child(b) + +func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D: + var f: StaticBody3D = TunnelFace.new() + f.name = "Face%d" % stage + f.tunnel = self + f.stage = stage + f.uid = "tunnel_face_%d" % stage + f.max_hp = SEAM_HP if stage == 0 else FACE_HP + f.hp = clampf(face_hp, 0.5, f.max_hp) + f.top_level = true + f.collision_layer = 1 + f.add_to_group("harvestable") + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = size + cs.shape = box + f.add_child(cs) + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = size + mi.mesh = bm + mi.material_override = _face_mat + f.add_child(mi) + add_child(f) + f.global_position = center + return f + +## The seam on the clifftop: an outcrop with a cracked face, then (dug) a +## dark doorway into the cliff. +func _build_seam() -> void: + _seam = Node3D.new() + _seam.name = "Seam" + _seam.top_level = true + add_child(_seam) + var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized() + _seam.global_transform = Transform3D(Basis.looking_at(inland, Vector3.UP), seam_at) + # (local -Z faces inland, towards the valley; the outcrop is behind, +Z) + var body := StaticBody3D.new() + _seam.add_child(body) + for part in [[Vector3(-1.5, 1.2, 0.4), Vector3(1.0, 2.6, 2.0)], [Vector3(1.5, 1.1, 0.4), Vector3(1.0, 2.4, 2.0)], + [Vector3(0, 2.6, 0.4), Vector3(4.0, 0.9, 2.2)], [Vector3(0, 1.1, 1.1), Vector3(2.2, 2.4, 0.6)]]: + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = part[1] + mi.mesh = bm + mi.material_override = _rock_mat + mi.position = part[0] + _seam.add_child(mi) + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = part[1] + cs.shape = box + cs.position = part[0] + body.add_child(cs) + if not opened: + _face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5)) + _face.global_basis = _seam.global_basis + else: + var dark := MeshInstance3D.new() + var q := QuadMesh.new() + q.size = Vector2(2.0, 2.1) + dark.mesh = q + var m := StandardMaterial3D.new() + m.albedo_color = Color(0.02, 0.02, 0.02) + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + dark.material_override = m + dark.position = Vector3(0, 1.05, 0.78) + dark.rotation.y = PI # (a quad faces +Z: turn it to face inland) + _seam.add_child(dark) + var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0) + _seam.add_child(spot) + spot.position = Vector3(0, 1.05, -0.2) + +## A box you can look at and press E on (layer 2: you walk through it). +func _spot_body(size: Vector3, what: String, k: int) -> StaticBody3D: + var s := StaticBody3D.new() + s.set_script(preload("res://scripts/world/tunnel_spot.gd")) + s.tunnel = self + s.what = what + s.k = k + s.collision_layer = 2 + s.collision_mask = 0 + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = size + cs.shape = box + s.add_child(cs) + return s + +func _add_spot(center: Vector3, size: Vector3, what: String, k: int) -> void: + var s := _spot_body(size, what, k) + _spots.add_child(s) + s.global_position = center + +## Two posts and a beam across the mouth of a stretch. +func _add_prop_mesh(mouth: Vector3) -> void: + for part in [[Vector3(0, H * 0.5 - 0.1, -W * 0.5 + 0.18), Vector3(0.22, H - 0.2, 0.22)], + [Vector3(0, H * 0.5 - 0.1, W * 0.5 - 0.18), Vector3(0.22, H - 0.2, 0.22)], + [Vector3(0, H - 0.25, 0), Vector3(0.26, 0.24, W)]]: + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = part[1] + mi.mesh = bm + mi.material_override = _wood_mat + _spots.add_child(mi) + mi.global_position = mouth + part[0] + +## The sea cave's mouth: grey daylight and the sound of the sea; walk into +## it to go out onto the Cove's shore. +func _add_mouth() -> void: + var glow := MeshInstance3D.new() + var q := QuadMesh.new() + q.size = Vector2(W + 0.8, H * 1.2) + glow.mesh = q + var m := StandardMaterial3D.new() + m.albedo_color = Color(0.75, 0.82, 0.88) + m.emission_enabled = true + m.emission = Color(0.6, 0.7, 0.78) + m.emission_energy_multiplier = 1.6 + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + glow.material_override = m + _spots.add_child(glow) + glow.global_position = cave_mouth + Vector3(-0.55, H * 0.6, 0.0) + glow.rotation.y = PI * 0.5 + var light := OmniLight3D.new() + light.light_color = Color(0.7, 0.8, 0.9) + light.light_energy = 1.2 + light.omni_range = 6.0 + _spots.add_child(light) + light.global_position = cave_mouth + Vector3(0.8, H * 0.6, 0.0) + var area := Area3D.new() + area.collision_layer = 0 + area.collision_mask = 1 | 2 | 4 | 8 | 16 | 32 + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(1.0, H, W + 0.6) + cs.shape = box + area.add_child(cs) + _spots.add_child(area) + area.global_position = cave_mouth + Vector3(0, H * 0.5, 0) + area.body_entered.connect(func(b): + if b == world.player: + world.travel(to, "sea_cave")) + _add_spot(cave_mouth + Vector3(1.2, H * 0.5, 0), Vector3(0.4, H, W), "cave", 0) diff --git a/scripts/world/tunnel.gd.uid b/scripts/world/tunnel.gd.uid new file mode 100644 index 0000000..0a11ae0 --- /dev/null +++ b/scripts/world/tunnel.gd.uid @@ -0,0 +1 @@ +uid://dxkd1tmf7rs5b diff --git a/scripts/world/tunnel_face.gd b/scripts/world/tunnel_face.gd new file mode 100644 index 0000000..7ea12e9 --- /dev/null +++ b/scripts/world/tunnel_face.gd @@ -0,0 +1,44 @@ +extends StaticBody3D +## A rock face to dig through (J1): the seam on the clifftop, or the end of +## the tunnel dug so far. It answers the same questions a boulder does +## (Actions.harvest_hit), so a swing of the pick works on it, but the tunnel +## (tunnel.gd) owns the rules: which pick, whether it's propped, and what +## happens when it gives. + +var tunnel: Node3D +var stage := 0 ## 0 = the seam; k = the face at the start of stretch k +var uid := "" +var tool_kind := "pickaxe" +var min_tier := 3 ## the antler pickaxe +var max_hp := 45.0 +var hp := 45.0 +var depleted := false +var kind := "rock" +var chip_color := Color(0.42, 0.40, 0.38) + +func display_name() -> String: + return "Cracked seam" if stage == 0 else "Rock face" + +## Why the next blow won't land ("" = it will), before the tool check. +func refusal() -> String: + return tunnel.dig_refusal(stage) + +func interact_prompt(_player: Node) -> String: + var why := refusal() + var left := ceili(hp / 3.0) + if not why.is_empty(): + return "%s: %s" % [display_name(), why] + return "%s: [LMB] dig with the antler pickaxe (about %d blows left)" % [display_name(), left] + +func interact(player: Node) -> void: + Actions.tell(player, interact_prompt(player)) + +## One blow (from Actions.harvest_hit). Returns what it knocks loose. +func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary: + hp = maxf(hp - damage, 0.0) + tunnel.face_hit(self) + if hp <= 0.0 and not depleted: + depleted = true + tunnel.face_broken(self) + return {"stone": 3} + return {"stone": 1} if randf() < 0.4 else {} diff --git a/scripts/world/tunnel_face.gd.uid b/scripts/world/tunnel_face.gd.uid new file mode 100644 index 0000000..41171c7 --- /dev/null +++ b/scripts/world/tunnel_face.gd.uid @@ -0,0 +1 @@ +uid://ctu24jeo68pkx diff --git a/scripts/world/tunnel_spot.gd b/scripts/world/tunnel_spot.gd new file mode 100644 index 0000000..a994146 --- /dev/null +++ b/scripts/world/tunnel_spot.gd @@ -0,0 +1,35 @@ +extends StaticBody3D +## Something to press E on in or by the cliff tunnel (tunnel.gd): the +## seam's doorway ("in"), the way out at the top ("out"), the mouth of a +## stretch that needs propping ("prop", stretch k), and the sea cave's +## mouth ("cave"). On the interactables layer, so you walk through it. + +var tunnel: Node3D +var what := "" +var k := 0 + +func interact_prompt(player: Node) -> String: + match what: + "in": + return "[E] Go down into the tunnel" + "out": + return "[E] Climb out onto the clifftop" + "prop": + var have: int = player.inventory.count("wood") + return "Unpropped: [E] Set a timber prop (%d wood, you have %d)" % [tunnel.PROP_WOOD, have] + "cave": + return "The sea cave: walk on out to the %s" % Terrain.worldlet_name(tunnel.to) + return "" + +func interact(player: Node) -> void: + match what: + "in": + tunnel.go_in(player) + "out": + tunnel.go_out(player) + "prop": + tunnel.set_prop(player, k) + "cave": + tunnel.world.travel(tunnel.to, "sea_cave") + +const Terrain = preload("res://scripts/world/terrain.gd") diff --git a/scripts/world/tunnel_spot.gd.uid b/scripts/world/tunnel_spot.gd.uid new file mode 100644 index 0000000..f762479 --- /dev/null +++ b/scripts/world/tunnel_spot.gd.uid @@ -0,0 +1 @@ +uid://wpqf817odym0 diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 7494367..1580f76 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -54,6 +54,9 @@ var _spawn_yaw := 0.0 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 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 var _autosave_left := AUTOSAVE_SECONDS var _menu_cam: Camera3D @@ -362,6 +365,25 @@ func _on_terrain_ready() -> void: props.pier = pr else: pr.free() + # The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea + # cave. Single-player for now (J3 brings travel online). + if not server_mode and not menu_mode and not is_online(): + var tn := preload("res://scripts/world/tunnel.gd").new() + tn.name = "Tunnel" + add_child(tn) + if tn.setup(self): + tunnel = tn + props.keep_clear.append([tn.seam_at, 4.0]) + else: + tn.free() + var sc := preload("res://scripts/world/sea_cave.gd").new() + sc.name = "SeaCave" + add_child(sc) + if sc.place(self): + sea_cave = sc + props.keep_clear.append([sc.global_position, 6.0]) + else: + sc.free() props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn) # The wolf den: part of the scenery, so it's there from day 1 (empty). den = preload("res://scripts/creatures/den.gd").new() @@ -424,6 +446,7 @@ func _on_terrain_ready() -> void: if not state.is_empty(): apply_save(state) print("[world] loaded '%s'" % Saves.current_id) + _arrive(Saves.take_arrival(Saves.current_id)) player.set_physics_process(true) set_physics_process(true) creatures.setup(self) @@ -652,6 +675,7 @@ func collect_save() -> Dictionary: "bear": bears.save_data() if bears != null else _saved_bear, "barrow": barrow.save_data() if barrow != null else {}, "stones": stones.save_data() if stones != null else {}, + "tunnel": tunnel.to_data() if tunnel != null else {}, } func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary: @@ -797,6 +821,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: barrow.load_data(s.get("barrow", {})) if stones != null: stones.load_data(s.get("stones", {})) + if tunnel != null: + tunnel.load_data(s.get("tunnel", {})) # The bear (bear_range.gd): applied when it's set up, after loading. _saved_bear = s.get("bear", {}) if bears != null: @@ -822,14 +848,82 @@ func _respawn() -> void: player.get_node("Camera3D").rotation.x = 0.0 func _physics_process(delta: float) -> void: - _current(delta) var p := player.global_position + var under := underground_at(p) + day_night.underground = move_toward(day_night.underground, 1.0 if under else 0.0, delta * 3.0) + if weather_fx != null: + weather_fx.visible = not under + # (No water down there: the tunnel is far below the lake's surface.) + player.water_level = -INF if under else terrain.water_level + if under: + return # (the tunnel is its own space, out of sight under the island) + _current(delta) var half: float = terrain.size * 0.5 if p.y < terrain.min_height - 30.0 or absf(p.x) > half or absf(p.z) > half \ or not terrain.inside_boundary(p.x, p.z, -4.0): push_warning("[world] player left the map at %s; respawning" % p) _respawn() +## In the cliff tunnel (J1): no daylight, no weather, sheltered. +func underground_at(p: Vector3) -> bool: + return tunnel != null and tunnel.contains(p) + +# --- the journey (J1, docs/WORLDLETS_DESIGN.md §10) ------------------------------ + +## Goes on to worldlet `to`: saves this one, takes your character (bag, +## stats, what you wear) and the clock along, and loads the other, arriving +## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own +## state file (Saves.state_path), so what you built here waits for you. +func travel(to: String, arrive: String) -> void: + if traveling or not Saves.has_world() or is_online() or server_mode: + return + traveling = true + var id := Saves.current_id + var here: Dictionary = collect_save() + Saves.save_state(id, here, _session_time) + _session_time = 0.0 + var there: Dictionary = Saves.load_state(id, to) + if there.is_empty(): + there = {"time": here.time} # (a new place: the same day and hour) + there["player"] = here.player + Saves.save_state(id, there, 0.0, to) + Saves.set_worldlet(id, to, arrive) + print("[world] travelling to %s (%s)" % [to, arrive]) + if hud != null: + hud.toast("On to the %s..." % Terrain.worldlet_name(to)) + travelled.emit(to) + if get_tree().current_scene == self: # (in a test the world isn't the scene: the test loads the next one) + get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn") + +signal travelled(to: String) ## saved and on the way to another worldlet + +## Just arrived from another worldlet: put you where you came in. +func _arrive(at: String) -> void: + if at.is_empty(): + return + var pos := Vector3.INF + var facing := Vector3.FORWARD + match at: + "sea_cave": + if sea_cave != null: + pos = sea_cave.front + facing = (sea_cave.front - sea_cave.global_position).normalized() + "tunnel_bottom": + if tunnel != null and tunnel.dug >= tunnel.STRETCHES: + pos = tunnel.bottom + Vector3(0.8, 0.0, 0.0) + facing = Vector3(1, 0, 0) + if pos == Vector3.INF: + push_warning("[world] nowhere to arrive at '%s': the start instead" % at) + _respawn() + return + player.global_position = pos + Vector3(0, 0.2, 0) + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0) + if hud != null: + hud.toast("The %s" % Terrain.worldlet_name(worldlet_id())) + +const Terrain = preload("res://scripts/world/terrain.gd") + ## Off a beach (W2), a current carries anyone swimming too far out back ## towards the shore: the sea's version of the invisible wall. It's faster ## than you can swim, so you can't push through it, and it says so once. @@ -858,9 +952,10 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool: if BuildPiece.is_build(def): return build.check(def, xf).is_empty() var p := xf.origin - if not terrain.inside_boundary(p.x, p.z, 1.5): + var under := underground_at(p) # (candles and torch stands down the tunnel: its floor is the ground) + if not under and not terrain.inside_boundary(p.x, p.z, 1.5): return false - if p.y < terrain.water_level + 0.15: + if not under and p.y < terrain.water_level + 0.15: return false var fp: Array = def.place.get("footprint", [1.0, 1.0]) var hx := float(fp[0]) * 0.5 diff --git a/tests/run_all.sh b/tests/run_all.sh index 10f58ff..7bb180c 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_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_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_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_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_tunnel.gd b/tests/test_tunnel.gd new file mode 100644 index 0000000..3212178 --- /dev/null +++ b/tests/test_tunnel.gd @@ -0,0 +1,259 @@ +extends SceneTree +## J1 (docs/WORLDLETS_DESIGN.md §10): the cliff tunnel, in single-player. Dig +## the seam on the Valley clifftop with the antler pickaxe (a lesser pick +## won't do), go down, dig six stretches (each propped before the next), +## light it, walk down it, out through the sea cave to the Cove, and back up +## the same tunnel to your chest at home. Each worldlet keeps its own save. +## Run: godot --headless --path . --script res://tests/test_tunnel.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var _toasts: Array[String] = [] +var _went := "" + +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: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + A.notify.connect(func(t): _toasts.append(t)) + S.root_dir = "user://test_tunnel_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_world() -> 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.weather.force("clear") + w.player.survival.set_process(false) + w.travelled.connect(func(to): _went = to) + return w + +func _drop_world(w: Node3D) -> void: + w.queue_free() + for _i in range(3): + await process_frame + +## Swings at `face` until it gives (or `limit` blows). Returns the blows. +func _dig(w: Node3D, face: Node3D, limit: int = 40) -> int: + var n := 0 + while is_instance_valid(face) and not face.depleted and n < limit: + w.player.survival.stamina = 100.0 + w.player.global_position = face.global_position - _toward(w, face) * 1.6 - Vector3(0, 1.0, 0) + if not A.harvest_hit(w.player, face, face.global_position): + break + n += 1 + await physics_frame + await physics_frame + return n + +func _toward(w: Node3D, face: Node3D) -> Vector3: + var d: Vector3 = face.global_position - w.player.global_position + d.y = 0.0 + return d.normalized() if d.length() > 0.01 else Vector3.FORWARD + +func _hold(player: Node, id: String) -> void: + var inv = player.inventory + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + break + player.select(0) + +func _body() -> void: + S.current_id = S.create_world("Journey", 1337) + var w: Node3D = await _load_world() + var t: Node3D = w.tunnel + var player: CharacterBody3D = w.player + print("the seam") + check(t != null, "the Valley has a cliff tunnel to dig (to the %s)" % (t.to if t else "-")) + if t == null: + return + var ter: Node3D = w.terrain + check(ter.inside_boundary(t.seam_at.x, t.seam_at.z, 1.0) and not ter.inside_boundary(t.seam_at.x, t.seam_at.z, 14.0) + and ter.sea_weight(t.seam_at.x, t.seam_at.z) > 0.5, "a seam on the clifftop, near the edge") + var seam: Node3D = t._face + check(seam != null and seam.stage == 0, "a cracked face to dig") + # Your hut: a chest by the start, to come back to. + var chest: Node3D = w.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), w._spawn + Vector3(3, 0, 0))) + var chest_at: Vector3 = chest.global_position + player.set_physics_process(false) + player.inventory.add("stone_pickaxe", 1) + _hold(player, "stone_pickaxe") + _toasts.clear() + await _dig(w, seam, 3) + check(not t.opened and _toasts.has("Too hard: you need a better pick"), "a stone pick won't touch it (%s)" % [_toasts]) + player.inventory.add("antler_pickaxe", 1) + _hold(player, "antler_pickaxe") + var blows := await _dig(w, seam) + check(t.opened, "the antler pickaxe opens it (%d blows)" % blows) + check(player.inventory.count("stone") > 0, "(and knocks loose some stone)") + + print("down into the dark") + t.go_in(player) + for _i in range(40): + await physics_frame + check(w.underground_at(player.global_position), "inside the tunnel") + check(w.day_night.underground > 0.95 and w.day_night.env.ambient_light_energy < 0.05 and not w.day_night.sun.visible, + "pitch dark: no sun, no sky (ambient %.3f)" % w.day_night.env.ambient_light_energy) + player.survival.step(0.3) + check(player.survival.roofed and player.survival.sheltered, "and out of the weather") + check(player.water_level == -INF, "(and no lake water down here, though it's far below the lake)") + + print("digging down") + var face1: Node3D = t._face + check(face1 != null and face1.stage == 1, "the first face, at the top") + blows = await _dig(w, face1) + check(t.dug == 1, "dug the first stretch (%d blows)" % blows) + var face2: Node3D = t._face + _toasts.clear() + await _dig(w, face2, 2) + check(t.dug == 1 and _toasts.any(func(s): return s.begins_with("It's loose")), "the next won't take a blow until it's propped") + player.inventory.remove("wood", player.inventory.count("wood")) + check(not t.set_prop(player, 1), "a prop needs wood") + player.inventory.add("wood", 20) + var wood0: int = player.inventory.count("wood") + check(t.set_prop(player, 1) and t.props[1], "set a timber prop (2 wood)") + await process_frame + for k in range(2, t.STRETCHES + 1): + await _dig(w, t._face) + if k < t.STRETCHES: + t.set_prop(player, k) + await process_frame + check(t.dug == t.STRETCHES, "dug all %d stretches down" % t.STRETCHES) + check(wood0 - player.inventory.count("wood") == 2 * (t.STRETCHES - 1), "propping it all took %d wood" % (wood0 - player.inventory.count("wood"))) + check(t.bottom.y < t.top.y - 20.0, "%.0f m down" % (t.top.y - t.bottom.y)) + check(_toasts.any(func(s): return s.begins_with("You break through into a sea cave")), "and broke through into a sea cave") + for _i in range(4): + await physics_frame + + print("walking it") + player.set_physics_process(true) + var stuck := "" + for k in range(1, t.STRETCHES + 1): + var m: Vector3 = t._mouth(k) + Vector3(0, 0.2, 0) + var dir := signf(t._landing_x(k) - t._landing_x(k - 1)) + player.global_position = m - Vector3(dir * 0.6, 0, 0) + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-dir, 0.0), 0.0) + Input.action_press("move_forward") + for _i in range(int(2.6 * Engine.physics_ticks_per_second)): + await physics_frame + Input.action_release("move_forward") + var p: Vector3 = player.global_position + var want_y: float = t.ORIGIN.y + t._floor_y(k) + if absf(p.y - want_y) > 0.6 or not w.underground_at(p): + stuck += " stretch %d ended at (%.1f, %.1f, %.1f), floor %.1f;" % [k, p.x, p.y, p.z, want_y] + check(stuck.is_empty(), "walk down every stretch to the landing below%s" % stuck) + stuck = "" + for k in range(t.STRETCHES, 0, -1): + var dir := signf(t._landing_x(k - 1) - t._landing_x(k)) # (back up: towards the landing above) + var foot: Vector3 = t.ORIGIN + Vector3(t._landing_x(k) + dir * (t.LAND * 0.5 - 0.6), t._floor_y(k) + 0.2, t._lane_z(k)) + player.global_position = foot + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-dir, 0.0), 0.0) + Input.action_press("move_forward") + for _i in range(int(3.4 * Engine.physics_ticks_per_second)): + await physics_frame + Input.action_release("move_forward") + var p: Vector3 = player.global_position + var want_y: float = t.ORIGIN.y + t._floor_y(k - 1) + if absf(p.y - want_y) > 0.6: + stuck += " stretch %d ended at (%.1f, %.1f, %.1f), floor %.1f;" % [k, p.x, p.y, p.z, want_y] + check(stuck.is_empty(), "and back up every stretch to the landing above%s" % stuck) + + print("light") + var floor_at: Vector3 = t._mouth(3) + Vector3(0.0, 0.0, 0.6) + floor_at.x += 0.8 * signf(t._landing_x(2) - t._landing_x(3)) + var xf := Transform3D(Basis(), floor_at) + check(w.placement_valid(DB.get_item("torch_stand"), xf), "a torch stand can stand on the tunnel floor") + player.set_physics_process(false) + player.global_position = floor_at + Vector3(1.0, 0, 0) + player.inventory.add("torch_stand", 1) + var slot := -1 + for i in range(player.inventory.size()): + var s = player.inventory.get_slot(i) + if s != null and s.id == "torch_stand": + slot = i + check(A.place(player, slot, xf, w), "set one down") + var lights: int = w.all_pieces().filter(func(p): return p.item_id == "torch_stand").size() + check(lights == 1, "(it's a piece like any other, so it stays: %d)" % lights) + + print("saved with the Valley") + w.save_now() + await _drop_world(w) + w = await _load_world() + t = w.tunnel + check(t.opened and t.dug == t.STRETCHES and t.props.count(true) == t.STRETCHES - 1, "the tunnel, dug and propped, after a reload") + check(w.all_pieces().any(func(p): return p.item_id == "torch_stand" and w.underground_at(p.global_position)), "the torch stand still down there") + player = w.player + + print("out to the Cove") + var stone0: int = player.inventory.count("stone") + var day0: int = w.day_night.day + player.set_physics_process(true) + player.global_position = t.cave_mouth + Vector3(4.0, 0.2, 0.0) + player.rotation = Vector3(0.0, atan2(1.0, 0.0), 0.0) # (facing -X, out of the cave) + _went = "" + Input.action_press("move_forward") + for _i in range(int(3.0 * Engine.physics_ticks_per_second)): + await physics_frame + if not _went.is_empty(): + break + Input.action_release("move_forward") + check(_went == "cove", "walking out of the sea cave's mouth goes on to the Cove") + check(S.worldlet_of(S.current_id) == "cove" and S.has_state(S.current_id, "cove"), "(the save is in the Cove now, with a file of its own)") + check(S.has_state(S.current_id, "valley"), "(and the Valley's is kept)") + await _drop_world(w) + w = await _load_world() + player = w.player + check(w.worldlet_id() == "cove", "the Cove") + check(w.sea_cave != null and player.global_position.distance_to(w.sea_cave.front) < 1.5, "arriving at the sea cave on its shore") + check(player.inventory.count("stone") == stone0 and player.inventory.count("antler_pickaxe") == 1, "with your bag as it was") + check(w.day_night.day == day0, "on the same day") + check(w.tunnel == null, "(the Cove has no tunnel of its own: it's the Valley's)") + + print("back up home") + check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)") + _went = "" + w.sea_cave.interact(player) + check(_went == "valley", "into the cave: back to the Valley") + await _drop_world(w) + w = await _load_world() + t = w.tunnel + player = w.player + check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0, + "arriving at the bottom of the tunnel") + t.go_out(player) + await physics_frame + check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop") + check(w.all_pieces().any(func(p): return p.item_id == "chest" and p.global_position.distance_to(chest_at) < 0.1), "and your chest is waiting at home") + await _drop_world(w) + + print("a world that never left home") + var id2: String = S.create_world("Homebody", 42) + check(S.state_path(id2, "valley").ends_with("/world.json"), "keeps its world.json (older saves just work)") + _finished = true diff --git a/tests/test_tunnel.gd.uid b/tests/test_tunnel.gd.uid new file mode 100644 index 0000000..2d675a9 --- /dev/null +++ b/tests/test_tunnel.gd.uid @@ -0,0 +1 @@ +uid://dgyh7ihkmdkxl diff --git a/tests/test_worldlet_pick.gd b/tests/test_worldlet_pick.gd index 83acf9a..88dfd54 100644 --- a/tests/test_worldlet_pick.gd +++ b/tests/test_worldlet_pick.gd @@ -52,14 +52,14 @@ func _body(S: Node, Net: Node) -> void: for _i in range(3): await process_frame var release: Array = TS.offered_worldlets(false) - check(release.size() == 1 and String(release[0].id) == "valley", "a release build offers only the Valley") + check(release.size() == 1 and String(release[0].id) == "valley", "normally only the Valley is offered") var dev: Array = TS.offered_worldlets(true) check(String(dev[0].id) == "valley" and dev.any(func(w): return w.id == "cove" and String(w.name) == "Cove (testing)"), - "running from the source, the Cove too, marked for testing (%s)" % [dev.map(func(w): return w.name)]) + "with --dev-worldlets, the Cove too, marked for testing (%s)" % [dev.map(func(w): return w.name)]) + check(not TS.dev_worldlets(), "(this test runs without --dev-worldlets)") var pick: OptionButton = title._worldlet_pick - check(pick != null and pick.item_count == dev.size() and pick.get_item_text(0) == "Valley", "a \"Where\" picker, on the Valley first") - check(not pick.disabled, "(with more than one to choose from)") - check(TS.picked_worldlet(pick) == "valley", "and gives the Valley") + check(pick == null, "New World has no \"Where\": the journey always begins in the Valley") + check(TS.picked_worldlet(pick) == "valley", "so a new world is the Valley") var id: String = S.create_world("Pick test", 4242, {"worldlet": TS.picked_worldlet(pick)}) var meta: Dictionary = S.list_worlds().filter(func(w): return w.id == id)[0] if not id.is_empty() else {} check(String(meta.get("worldlet", "")) == "valley", "a world made from it remembers the Valley (%s)" % meta.get("worldlet", "-")) @@ -74,7 +74,7 @@ func _body(S: Node, Net: Node) -> void: check(hp != null and hp.get_item_text(hp.selected) == "Valley", "a \"Where\" picker too, on the Valley") title._show("host") await process_frame - check(not hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley to start with, free to change") + check(hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley (the only start; One land mode will widen it)") # Once the island exists it keeps its worldlet: the picker shows that one. DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world") var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE)