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)

This commit is contained in:
Paul Bailey
2026-11-06 17:51:58 +00:00
parent 793b214786
commit 980ee53579
22 changed files with 1181 additions and 33 deletions

View File

@@ -1,7 +1,34 @@
# Worldlets design # Worldlets design
Status: **W0-W3 built** (the Cove, without its stream yet); the rest is Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
planned. The journey between 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/<id>.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 worldlets is §10, and the two ways to play (the journey, or one land) are in
its decisions. its decisions.
@@ -17,9 +44,10 @@ its decisions.
- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites - 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, in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire,
salted, or smoked, like the lake's. salted, or smoked, like the lake's.
- Running from the source, the New World and Host pages also offer - New World has no "Where": the journey always begins in the Valley (the
worldlets that aren't starts, marked "(testing)", so the Cove can be Host page keeps it, for One land mode). Started with
played before the tunnel exists. Release builds offer only the Valley. `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. - No sea stacks without a sea cliff.
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod - 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 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 - **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 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 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 - **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 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** with the pick, so the way to do it is to set **candles and torch stands**

View File

@@ -257,6 +257,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
return false return false
var held: Dictionary = ItemDB.get_item(player.held_item_id()) var held: Dictionary = ItemDB.get_item(player.held_item_id())
var tool: Dictionary = held.get("tool", {}) 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: 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)) notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
return false return false

View File

@@ -60,6 +60,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
} }
for k in options: for k in options:
meta[k] = options[k] meta[k] = options[k]
meta["home"] = String(meta.worldlet)
_write_json(_dir(id).path_join("meta.json"), meta) _write_json(_dir(id).path_join("meta.json"), meta)
return id return id
@@ -84,6 +85,8 @@ func delete_world(id: String) -> void:
var d := DirAccess.open(dir) var d := DirAccess.open(dir)
if d == null: if d == null:
return 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(): for f in d.get_files():
d.remove(f) d.remove(f)
DirAccess.remove_absolute(dir) DirAccess.remove_absolute(dir)
@@ -93,16 +96,64 @@ func delete_world(id: String) -> void:
func read_meta(id: String) -> Dictionary: func read_meta(id: String) -> Dictionary:
return _read_json(_dir(id).path_join("meta.json")) return _read_json(_dir(id).path_join("meta.json"))
func load_state(id: String) -> Dictionary: # --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) --------------------
var s := _read_json(_dir(id).path_join("world.json")) # A world can span several worldlets (the journey). Each keeps its own state
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")): # file: the one it began in (its "home") is world.json, as before, so older
push_warning("Saves: world.json unreadable, using backup") # saves are unchanged; the others are worldlets/<id>.json. meta.json's
s = _read_json(_dir(id).path_join("world.json.bak")) # "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 return s
## Writes the state for `id` and updates its meta. Returns true on success. ## Writes the state for `id` (the worldlet being played, or `w`) and
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool: ## updates its meta. Returns true on success.
var path := _dir(id).path_join("world.json") 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): if FileAccess.file_exists(path):
DirAccess.copy_absolute(path, path + ".bak") DirAccess.copy_absolute(path, path + ".bak")
state["version"] = VERSION state["version"] = VERSION
@@ -129,3 +180,10 @@ func _read_json(path: String) -> Dictionary:
return {} return {}
var data = JSON.parse_string(FileAccess.get_file_as_string(path)) var data = JSON.parse_string(FileAccess.get_file_as_string(path))
return data if typeof(data) == TYPE_DICTIONARY else {} 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)

View File

@@ -103,8 +103,9 @@ func step(dt: float) -> void:
_shelter_left -= dt _shelter_left -= dt
if _shelter_left <= 0.0: if _shelter_left <= 0.0:
_shelter_left = 0.25 _shelter_left = 0.25
roofed = world.build.roofed_at(p) var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round)
sheltered = roofed and world.build.enclosure_at(p) >= 0.74 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 stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
cozy = sheltered and _rug_near(p) cozy = sheltered and _rug_near(p)
# Rain soaks you unless there's a roof over your head. # Rain soaks you unless there's a roof over your head.

View File

@@ -594,7 +594,10 @@ func _new_page() -> Control:
_rot_pick.add_item(t[1]) _rot_pick.add_item(t[1])
_rot_pick.custom_minimum_size = Vector2(360, 36) _rot_pick.custom_minimum_size = Vector2(360, 36)
v.add_child(_rot_pick) 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() var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10) h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170)) 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 ## description under it. (Only the Valley today: the others are reached on
## the journey, so the list is short and the picker sits still.) ## the journey, so the list is short and the picker sits still.)
func _worldlet_picker(v: VBoxContainer) -> OptionButton: 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)) v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
var pick := OptionButton.new() var pick := OptionButton.new()
pick.custom_minimum_size = Vector2(360, 36) 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", ""))) about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
return pick return pick
## The worldlets offered for a new world: the starts, and (running from the ## Started with `-- --dev-worldlets`: every worldlet can be picked, for
## source, `debug`) the rest too, marked for testing, so a worldlet can be ## trying one before the journey reaches it.
## tried before the journey reaches it. A release build offers only the starts. static func dev_worldlets() -> bool:
static func offered_worldlets(debug: bool) -> Array: 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() var out: Array = Terrain.starting_worldlets()
if debug: if dev:
for w in Terrain.worldlets(): for w in Terrain.worldlets():
if not w.start: if not w.start:
var t: Dictionary = w.duplicate() var t: Dictionary = w.duplicate()

View File

@@ -15,6 +15,8 @@ const DAY_BIAS := 0.2
var day := 1 var day := 1
var time := 8.0 / 24.0 var time := 8.0 / 24.0
var frozen := false ## title-screen backdrop: hold a fixed time 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 sun: DirectionalLight3D
var moon: 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.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.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) 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 env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time() _sky_timer -= get_process_delta_time()

View File

@@ -32,6 +32,7 @@ const FLOWER_COLORS := [
var terrain: Node3D var terrain: Node3D
var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world) 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 _player: Node3D
var _spawn := Vector3.ZERO var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new() var _forest_noise := FastNoiseLite.new()
@@ -422,6 +423,9 @@ func _scatter_trees() -> void:
func _on_clifftop(x: float, z: float) -> bool: func _on_clifftop(x: float, z: float) -> bool:
if pier != null and pier.covers(x, z, 2.0): if pier != null and pier.covers(x, z, 2.0):
return true 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) \ 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) and terrain.inside_boundary(x, z, -8.0)

120
scripts/world/sea_cave.gd Normal file
View File

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

View File

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

View File

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

View File

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

451
scripts/world/tunnel.gd Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,9 @@ var _spawn_yaw := 0.0
var _pieces: Node3D var _pieces: Node3D
var hud: CanvasLayer var hud: CanvasLayer
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null 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 _session_time := 0.0 ## seconds played since the last save
var _autosave_left := AUTOSAVE_SECONDS var _autosave_left := AUTOSAVE_SECONDS
var _menu_cam: Camera3D var _menu_cam: Camera3D
@@ -362,6 +365,25 @@ func _on_terrain_ready() -> void:
props.pier = pr props.pier = pr
else: else:
pr.free() 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) 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). # The wolf den: part of the scenery, so it's there from day 1 (empty).
den = preload("res://scripts/creatures/den.gd").new() den = preload("res://scripts/creatures/den.gd").new()
@@ -424,6 +446,7 @@ func _on_terrain_ready() -> void:
if not state.is_empty(): if not state.is_empty():
apply_save(state) apply_save(state)
print("[world] loaded '%s'" % Saves.current_id) print("[world] loaded '%s'" % Saves.current_id)
_arrive(Saves.take_arrival(Saves.current_id))
player.set_physics_process(true) player.set_physics_process(true)
set_physics_process(true) set_physics_process(true)
creatures.setup(self) creatures.setup(self)
@@ -652,6 +675,7 @@ func collect_save() -> Dictionary:
"bear": bears.save_data() if bears != null else _saved_bear, "bear": bears.save_data() if bears != null else _saved_bear,
"barrow": barrow.save_data() if barrow != null else {}, "barrow": barrow.save_data() if barrow != null else {},
"stones": stones.save_data() if stones != 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: 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", {})) barrow.load_data(s.get("barrow", {}))
if stones != null: if stones != null:
stones.load_data(s.get("stones", {})) 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. # The bear (bear_range.gd): applied when it's set up, after loading.
_saved_bear = s.get("bear", {}) _saved_bear = s.get("bear", {})
if bears != null: if bears != null:
@@ -822,14 +848,82 @@ func _respawn() -> void:
player.get_node("Camera3D").rotation.x = 0.0 player.get_node("Camera3D").rotation.x = 0.0
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_current(delta)
var p := player.global_position 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 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 \ 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): or not terrain.inside_boundary(p.x, p.z, -4.0):
push_warning("[world] player left the map at %s; respawning" % p) push_warning("[world] player left the map at %s; respawning" % p)
_respawn() _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 ## 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 ## 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. ## 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): if BuildPiece.is_build(def):
return build.check(def, xf).is_empty() return build.check(def, xf).is_empty()
var p := xf.origin 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 return false
if p.y < terrain.water_level + 0.15: if not under and p.y < terrain.water_level + 0.15:
return false return false
var fp: Array = def.place.get("footprint", [1.0, 1.0]) var fp: Array = def.place.get("footprint", [1.0, 1.0])
var hx := float(fp[0]) * 0.5 var hx := float(fp[0]) * 0.5

View File

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

259
tests/test_tunnel.gd Normal file
View File

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

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

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

View File

@@ -52,14 +52,14 @@ func _body(S: Node, Net: Node) -> void:
for _i in range(3): for _i in range(3):
await process_frame await process_frame
var release: Array = TS.offered_worldlets(false) 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) 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)"), 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 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(pick == null, "New World has no \"Where\": the journey always begins in the Valley")
check(not pick.disabled, "(with more than one to choose from)") check(TS.picked_worldlet(pick) == "valley", "so a new world is the Valley")
check(TS.picked_worldlet(pick) == "valley", "and gives the Valley")
var id: String = S.create_world("Pick test", 4242, {"worldlet": TS.picked_worldlet(pick)}) 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 {} 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", "-")) 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") check(hp != null and hp.get_item_text(hp.selected) == "Valley", "a \"Where\" picker too, on the Valley")
title._show("host") title._show("host")
await process_frame 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. # Once the island exists it keeps its worldlet: the picker shows that one.
DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world") DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world")
var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE) var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE)