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

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

View File

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

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