Files
Cinderwild/scripts/world/tunnel.gd

482 lines
18 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 RockPile = preload("res://scripts/world/rock_pile.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 ## the tunnel's walls
var _boulder_mat: ShaderMaterial ## the seam's outcrop (rock_pile.gd)
var _face_mat: ShaderMaterial ## the faces you dig
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)
_boulder_mat = RockPile.material(terrain)
_face_mat = RockPile.material(terrain) # (the same rock: the seam's cleft and pick marks set it apart)
_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)
add_child(f)
var s: int = terrain.noise_seed + 4200 + stage * 7
if stage == 0:
# The seam: two slabs of rock with a dark cleft between them, and
# old pick marks (pale chips) on either side.
var gap := MeshInstance3D.new()
var gm := BoxMesh.new()
gm.size = Vector3(0.28, size.y * 0.85, 0.3)
gap.mesh = gm
var dm := StandardMaterial3D.new()
dm.albedo_color = Color(0.02, 0.018, 0.015)
dm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
gap.material_override = dm
gap.position = Vector3(0.05, -0.05, 0.05)
f.add_child(gap)
RockPile.add(f, null, s, Vector3(-0.52, 0.0, 0.1), Vector3(0.55, size.y * 0.5, 0.42), 0.3, _face_mat, true)
RockPile.add(f, null, s + 1, Vector3(0.58, -0.05, 0.12), Vector3(0.52, size.y * 0.47, 0.4), 2.1, _face_mat, true)
var chip := StandardMaterial3D.new()
chip.albedo_color = Color(0.62, 0.58, 0.52)
for c in [Vector3(-0.32, 0.35, -0.3), Vector3(0.36, -0.2, -0.3), Vector3(-0.25, -0.45, -0.28)]:
var m := MeshInstance3D.new()
var cm := BoxMesh.new()
cm.size = Vector3(0.12, 0.05, 0.03)
m.mesh = cm
m.material_override = chip
m.position = c
m.rotation.z = c.x * 2.0
f.add_child(m)
else:
# A face down the tunnel: rough rock filling the passage.
RockPile.add(f, null, s, Vector3(-0.05, 0.0, 0.0), Vector3(0.45, size.y * 0.55, size.z * 0.55), 0.1, _face_mat)
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)
# An outcrop of boulders round a cleft: two tall ones either side, one
# across the top, a big one behind and two lower ones at the shoulders,
# with loose stones fallen at its foot. (Each sits a little into the
# ground, which isn't quite flat.)
var s: int = terrain.noise_seed + 4100
for part in [[Vector3(-2.05, 0.95, 0.5), Vector3(1.0, 1.55, 1.3), 0.4, true],
[Vector3(2.05, 0.85, 0.55), Vector3(0.95, 1.45, 1.25), 1.9, true],
[Vector3(0.0, 2.95, 0.65), Vector3(2.3, 0.7, 1.4), 0.2, false],
[Vector3(0.0, 1.4, 1.9), Vector3(2.3, 2.3, 1.3), 3.0, false],
[Vector3(-1.2, 2.6, 2.4), Vector3(1.7, 1.5, 1.3), 1.3, false],
[Vector3(1.3, 2.5, 2.3), Vector3(1.6, 1.4, 1.3), 2.5, false],
[Vector3(-3.3, 0.35, 1.2), Vector3(1.2, 0.9, 1.2), 2.2, false],
[Vector3(3.2, 0.3, 1.0), Vector3(1.0, 0.8, 1.1), 0.9, false]]:
RockPile.add(_seam, body, s, part[0], part[1], part[2], _boulder_mat, part[3])
s += 1
for stone in [[Vector3(-0.8, 0.06, -1.0), 0.28], [Vector3(1.1, 0.05, -1.3), 0.22],
[Vector3(0.35, 0.04, -1.7), 0.18], [Vector3(-1.6, 0.04, -1.6), 0.2]]:
RockPile.add(_seam, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), _boulder_mat)
s += 1
if not opened:
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.2), Vector3(2.0, 2.2, 0.6))
_face.global_basis = _seam.global_basis
else:
# The cleft dug open: black inside, set back between the boulders.
var dark := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(1.6, 2.0, 0.1)
dark.mesh = bm
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.015, 0.012, 0.01)
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dark.material_override = m
dark.position = Vector3(0, 1.0, 0.45)
_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)