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)