452 lines
16 KiB
GDScript
452 lines
16 KiB
GDScript
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)
|