452 lines
17 KiB
GDScript
452 lines
17 KiB
GDScript
extends Node3D
|
|
## The barrow (docs/PROGRESSION_DESIGN.md §4): an ancient burial mound in a
|
|
## ring of standing stones, out on open ground. Scenery, like the wolf den:
|
|
## built from the seed on every machine, so it's the same for everyone.
|
|
##
|
|
## A stone-lined passage leads under the mound into a chamber with a stone
|
|
## coffin at the back. Three skeletal guards (guard.gd) stand inside. They
|
|
## wake when someone's inside the stone ring at night (or at any time after
|
|
## day WAKE_DAY), when the coffin is opened, or when one of them is hit.
|
|
## The coffin holds iron and a barrow key piece (three open the standing
|
|
## stones on the rim, P5). It fills again RESTOCK_DAYS after it was emptied,
|
|
## and fallen guards are back at their posts then too.
|
|
##
|
|
## Guards and loot run on the server (or in single-player): start().
|
|
## Saved with the world: when the coffin was emptied and which guards fell.
|
|
##
|
|
## Local space: +Z is the passage's mouth (facing the centre of the valley),
|
|
## the origin is the chamber floor.
|
|
|
|
const IM = preload("res://scripts/items/item_meshes.gd")
|
|
const P = preload("res://scripts/world/proc_mesh.gd")
|
|
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
|
|
|
const MOUND_R := 6.5
|
|
const MOUND_H := 3.6
|
|
const RING_R := 10.0 ## the standing stones
|
|
const CLEAR_R := 11.5
|
|
const LEASH := 22.0 ## guards don't follow you further than this from the centre
|
|
const WAKE_DAY := 10 ## after this, they wake by day too
|
|
const RESTOCK_DAYS := 5
|
|
const PASSAGE_END := 6.4 ## the mouth, local z
|
|
const POSTS := [[Vector3(-1.4, 0, -0.4), 0.0], [Vector3(1.4, 0, -0.4), 0.0], [Vector3(0.0, 0, 1.2), 0.0]]
|
|
const LOOT := {"iron_bar": [1, 3], "iron_nugget": [3, 6], "barrow_key": [1, 1]}
|
|
|
|
const STONE := Color(0.5, 0.5, 0.47)
|
|
const MOSS := Color(0.32, 0.38, 0.22)
|
|
const TURF := Color(0.3, 0.4, 0.18)
|
|
const EARTH := Color(0.36, 0.3, 0.2)
|
|
|
|
var world: Node3D
|
|
var terrain: Node3D
|
|
var found := false
|
|
var yaw := 0.0
|
|
var alarmed := false ## guards stirred (the coffin was opened, one was hit)
|
|
var loot_day := -1 ## the day it was emptied (-1: never)
|
|
var fallen: Array = [] ## post indices whose guard is dead
|
|
var guards: Array = []
|
|
var running := false ## this machine runs the guards (server / single-player)
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _mouth_drop := 0.0 ## how far the ground at the mouth is below the floor
|
|
|
|
# --- placing it -----------------------------------------------------------------------
|
|
|
|
func place(w: Node3D) -> bool:
|
|
world = w
|
|
terrain = w.terrain
|
|
var site := _find_site()
|
|
if site == Vector3.INF:
|
|
push_warning("[barrow] no site for the barrow on this seed")
|
|
return false
|
|
found = true
|
|
position = site
|
|
yaw = atan2(-site.x, -site.z) # mouth toward the middle of the valley
|
|
rotation.y = yaw
|
|
var mouth: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, PASSAGE_END)
|
|
_mouth_drop = maxf(position.y - terrain.sample_height(mouth.x, mouth.z), 0.0)
|
|
_clear_area(w.props)
|
|
w.props.clear_bushes(position, CLEAR_R)
|
|
_build(w.props._rock_mat)
|
|
w.props.static_cuts.append([Transform3D(Basis(Vector3.UP, yaw), position), MOUND_R + 0.5, MOUND_R + 1.5])
|
|
return true
|
|
|
|
## Open, fairly flat ground away from the spawn, the den and the sea. Where
|
|
## nothing qualifies (a lumpy island), later passes accept rougher ground
|
|
## nearer the spawn and the den; the first pass is the original search, so
|
|
## islands that had a site keep it.
|
|
func _find_site() -> Vector3:
|
|
for pass_ in [[1.2, 55.0, 70.0], [2.0, 45.0, 50.0], [3.0, 40.0, 40.0]]:
|
|
var c: Vector3 = _search(float(pass_[0]), float(pass_[1]), float(pass_[2]))
|
|
if c != Vector3.INF:
|
|
return c
|
|
return Vector3.INF
|
|
|
|
func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
|
|
_rng.seed = terrain.noise_seed + 555
|
|
var spawn: Vector3 = world._spawn
|
|
var den_at: Vector3 = world.den.position if world.den != null and world.den.found else Vector3(1e9, 0, 1e9)
|
|
var best := Vector3.INF
|
|
var best_score := INF
|
|
for _i in range(600):
|
|
var a := _rng.randf() * TAU
|
|
var rr := _rng.randf_range(40.0, terrain.play_radius - 20.0)
|
|
var c := Vector2(cos(a), sin(a)) * rr
|
|
if not terrain.inside_boundary(c.x, c.y, CLEAR_R + 4.0) or terrain.sea_weight(c.x, c.y) > 0.02:
|
|
continue
|
|
if c.distance_to(Vector2(spawn.x, spawn.z)) < from_spawn or c.distance_to(Vector2(den_at.x, den_at.z)) < from_den:
|
|
continue
|
|
var hs: Array[float] = []
|
|
for k in range(8):
|
|
var b := TAU * k / 8.0
|
|
for rad in [MOUND_R * 0.5, PASSAGE_END + 0.5]:
|
|
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
|
|
var h0: float = terrain.sample_height(c.x, c.y)
|
|
hs.append(h0)
|
|
if hs.min() < terrain.water_level + 1.0:
|
|
continue
|
|
var spread: float = hs.max() - hs.min()
|
|
if spread > max_spread:
|
|
continue
|
|
var trees := 0
|
|
for n in world.props.resource_nodes.values():
|
|
if Vector2(n.global_position.x - c.x, n.global_position.z - c.y).length() < CLEAR_R:
|
|
trees += 1
|
|
var score: float = spread * 4.0 + trees
|
|
if score < best_score:
|
|
best_score = score
|
|
# Floor level: the highest ground inside the chamber, so the
|
|
# terrain doesn't come up through the floor.
|
|
var top := h0
|
|
for d in [Vector2(-2, -2), Vector2(2, -2), Vector2(-2, 2), Vector2(2, 2)]:
|
|
top = maxf(top, terrain.sample_height(c.x + d.x, c.y + d.y))
|
|
best = Vector3(c.x, top + 0.03, c.y)
|
|
return best
|
|
|
|
func _clear_area(props: Node3D) -> void:
|
|
for dict in [props.resource_nodes, props.pickups, props.forage]:
|
|
for uid in dict.keys():
|
|
var n: Node3D = dict[uid]
|
|
if not is_instance_valid(n):
|
|
continue
|
|
if Vector2(n.global_position.x - position.x, n.global_position.z - position.z).length() < CLEAR_R:
|
|
if n.has_method("_set_instance"):
|
|
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
|
|
dict.erase(uid)
|
|
n.queue_free()
|
|
|
|
# --- building it -----------------------------------------------------------------------
|
|
|
|
var _body: StaticBody3D
|
|
var _st: SurfaceTool
|
|
var _stone_rng := RandomNumberGenerator.new()
|
|
|
|
## A dressed stone block: drawn and solid.
|
|
func _block(c: Vector3, s: Vector3, basis: Basis = Basis()) -> void:
|
|
var col := P._mul(STONE, _stone_rng.randf_range(0.8, 1.05)).lerp(MOSS, _stone_rng.randf() * 0.35)
|
|
IM._box(_st, c, s, col, basis)
|
|
var cs := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = s
|
|
cs.shape = box
|
|
cs.transform = Transform3D(basis, c)
|
|
_body.add_child(cs)
|
|
|
|
func _build(rock_mat: Material) -> void:
|
|
_stone_rng.seed = terrain.noise_seed + 556
|
|
_body = StaticBody3D.new()
|
|
_body.name = "Stones"
|
|
_body.collision_layer = 1 | 4 # solid, and a roof for shelter checks
|
|
add_child(_body)
|
|
_st = P._begin()
|
|
# Chamber: 4 x 4 m inside, 2.4 m high, a slab roof.
|
|
_block(Vector3(0, -0.15, 0), Vector3(4.8, 0.36, 4.8)) # floor (top at 0.03)
|
|
_block(Vector3(0, 1.2, -2.2), Vector3(4.8, 2.4, 0.4)) # back
|
|
for s in [-1.0, 1.0]:
|
|
_block(Vector3(s * 2.2, 1.2, 0), Vector3(0.4, 2.4, 4.0)) # sides
|
|
_block(Vector3(s * 1.4, 1.2, 2.2), Vector3(1.6, 2.4, 0.4)) # front, either side of the passage
|
|
_block(Vector3(0, 2.6, 0), Vector3(4.8, 0.4, 4.8)) # roof
|
|
_block(Vector3(0, 2.15, 2.2), Vector3(1.2, 0.5, 0.4)) # over the passage's inner end
|
|
# Passage: 1.2 m wide, 1.95 m high, from the chamber to the mouth. Its
|
|
# floor slopes down to the ground at the mouth.
|
|
var plen := PASSAGE_END - 2.4
|
|
var mid := 2.4 + plen * 0.5
|
|
var tilt := atan2(_mouth_drop, plen)
|
|
_block(Vector3(0, -0.15 - _mouth_drop * 0.5, mid), Vector3(1.6, 0.36, sqrt(plen * plen + _mouth_drop * _mouth_drop)), Basis(Vector3.RIGHT, tilt))
|
|
for s in [-1.0, 1.0]:
|
|
_block(Vector3(s * 0.8, 1.0 - _mouth_drop * 0.5, mid), Vector3(0.4, 2.0 + _mouth_drop, plen))
|
|
_block(Vector3(0, 2.1, mid), Vector3(2.0, 0.3, plen))
|
|
# The mouth: two tall jambs and a lintel.
|
|
for s in [-1.0, 1.0]:
|
|
_block(Vector3(s * 0.95, 1.1 - _mouth_drop * 0.5, PASSAGE_END + 0.1), Vector3(0.5, 2.6 + _mouth_drop, 0.5))
|
|
_block(Vector3(0, 2.45, PASSAGE_END + 0.1), Vector3(2.6, 0.45, 0.6))
|
|
# The coffin at the back: a stone chest with a lid.
|
|
_coffin = Coffin.new()
|
|
_coffin.barrow = self
|
|
_coffin.collision_layer = 1
|
|
_coffin.position = Vector3(0, 0, -1.45) # (its origin is what reach is measured to)
|
|
add_child(_coffin)
|
|
var ccs := CollisionShape3D.new()
|
|
var cbox := BoxShape3D.new()
|
|
cbox.size = Vector3(1.9, 0.85, 0.8)
|
|
ccs.shape = cbox
|
|
ccs.position = Vector3(0, 0.45, 0)
|
|
_coffin.add_child(ccs)
|
|
IM._box(_st, Vector3(0, 0.4, -1.45), Vector3(1.8, 0.75, 0.75), P._mul(STONE, 0.85))
|
|
IM._box(_st, Vector3(0, 0.82, -1.45), Vector3(1.95, 0.12, 0.85), STONE)
|
|
IM._box(_st, Vector3(0, 0.9, -1.45), Vector3(0.9, 0.05, 0.3), MOSS)
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = P._commit(_st)
|
|
mi.material_override = ItemDB.item_material()
|
|
add_child(mi)
|
|
_build_mound()
|
|
_build_ring(rock_mat)
|
|
|
|
## The grass mound over the chamber: a dome that meets the ground at its
|
|
## edge, open where the passage goes in. Its surface is solid (you can climb
|
|
## onto it); inside it you see the stone.
|
|
func _build_mound() -> void:
|
|
var st := P._begin()
|
|
var faces := PackedVector3Array()
|
|
var rings := 9
|
|
var segs := 36
|
|
var nz := FastNoiseLite.new()
|
|
nz.seed = terrain.noise_seed + 557
|
|
var b := Basis(Vector3.UP, yaw)
|
|
var pt := func(i: int, j: int) -> Vector3:
|
|
var rr: float = MOUND_R * float(i) / rings
|
|
var a: float = TAU * float(j) / segs
|
|
var x: float = sin(a) * rr
|
|
var z: float = cos(a) * rr
|
|
var w: Vector3 = position + b * Vector3(x, 0, z)
|
|
var ground: float = terrain.sample_height(w.x, w.z) - position.y
|
|
var k: float = rr / MOUND_R
|
|
return Vector3(x, lerpf(MOUND_H, ground - 0.3, k * k) + nz.get_noise_2d(x * 3.0, z * 3.0) * 0.12 * (1.0 - k), z)
|
|
for i in range(rings):
|
|
for j in range(segs):
|
|
var q := [pt.call(i, j), pt.call(i + 1, j), pt.call(i + 1, j + 1), pt.call(i, j + 1)]
|
|
for tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]:
|
|
var c: Vector3 = (tri[0] + tri[1] + tri[2]) / 3.0
|
|
if absf(c.x) < 1.05 and c.z > 2.0:
|
|
continue # the passage
|
|
if (tri[0] as Vector3).distance_to(tri[1]) < 0.001 or (tri[1] as Vector3).distance_to(tri[2]) < 0.001:
|
|
continue # (degenerate, at the top)
|
|
var col := TURF.lerp(EARTH, clampf(0.4 - c.y * 0.12, 0.0, 0.5))
|
|
P._tri(st, tri[0], tri[1], tri[2], c - Vector3(0, -3, 0), P._mul(col, 0.9 + 0.2 * nz.get_noise_2d(c.x * 7.0, c.z * 7.0)))
|
|
var nrm: Vector3 = ((tri[1] as Vector3) - tri[0]).cross((tri[2] as Vector3) - tri[0])
|
|
if nrm.dot(c - Vector3(0, -3, 0)) < 0.0:
|
|
faces.append_array(PackedVector3Array([tri[0], tri[2], tri[1]]))
|
|
else:
|
|
faces.append_array(PackedVector3Array([tri[0], tri[1], tri[2]]))
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = "Mound"
|
|
mi.mesh = P._commit(st)
|
|
mi.material_override = ItemDB.item_material()
|
|
add_child(mi)
|
|
var shape := ConcavePolygonShape3D.new()
|
|
shape.set_faces(faces)
|
|
shape.backface_collision = true
|
|
var cs := CollisionShape3D.new()
|
|
cs.shape = shape
|
|
var body := StaticBody3D.new()
|
|
body.name = "MoundBody"
|
|
body.collision_layer = 1 | 4
|
|
body.add_child(cs)
|
|
add_child(body)
|
|
|
|
## Standing stones round the mound, with a gap in front of the mouth.
|
|
func _build_ring(rock_mat: Material) -> void:
|
|
var body := StaticBody3D.new()
|
|
body.name = "Ring"
|
|
body.collision_layer = 1
|
|
add_child(body)
|
|
var n := 11
|
|
for i in range(n):
|
|
var a := TAU * (i + 0.5) / n
|
|
if absf(wrapf(a, -PI, PI)) < 0.3:
|
|
continue # (the mouth faces +Z: a = 0)
|
|
var lp := Vector3(sin(a) * RING_R, 0, cos(a) * RING_R)
|
|
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
|
|
var radii := Vector3(0.55, 1.5 + 0.35 * sin(i * 2.3), 0.4)
|
|
var y: float = terrain.sample_height(w.x, w.z) - position.y + radii.y * 0.8 - 0.3
|
|
var mesh := P.rock(terrain.noise_seed + 5200 + i, false)
|
|
var turn := Basis(Vector3.UP, a + 0.2 * sin(i * 1.7)) * Basis(Vector3.RIGHT, 0.05 * sin(i * 3.1))
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = mesh
|
|
mi.material_override = rock_mat
|
|
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
|
|
add_child(mi)
|
|
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
|
|
var pts := PackedVector3Array()
|
|
for p: Vector3 in hull.points:
|
|
pts.append(turn * (p * radii))
|
|
hull.points = pts
|
|
var cs := CollisionShape3D.new()
|
|
cs.shape = hull
|
|
cs.position = Vector3(lp.x, y, lp.z)
|
|
body.add_child(cs)
|
|
|
|
# --- where things are --------------------------------------------------------------------
|
|
|
|
func center() -> Vector3:
|
|
return position
|
|
|
|
func to_world(lp: Vector3) -> Vector3:
|
|
return position + Basis(Vector3.UP, yaw) * lp
|
|
|
|
func to_local_pos(p: Vector3) -> Vector3:
|
|
return Basis(Vector3.UP, yaw).inverse() * (p - position)
|
|
|
|
## Inside the chamber or passage.
|
|
func inside(p: Vector3) -> bool:
|
|
var l := to_local_pos(p)
|
|
return (absf(l.x) < 2.0 and absf(l.z) < 2.0 or absf(l.x) < 0.6 and l.z > 1.9 and l.z < PASSAGE_END + 0.3) and l.y > -1.0 and l.y < 2.6
|
|
|
|
## The stone floor's height at `p` (world), or -INF outside.
|
|
func floor_y_at(p: Vector3) -> float:
|
|
var l := to_local_pos(p)
|
|
if absf(l.x) < 2.0 and absf(l.z) < 2.0:
|
|
return position.y + 0.03
|
|
if absf(l.x) < 0.6 and l.z >= 2.0 and l.z < PASSAGE_END:
|
|
return position.y + 0.03 - _mouth_drop * (l.z - 2.4) / (PASSAGE_END - 2.4) * float(l.z > 2.4)
|
|
return -INF
|
|
|
|
func mouth() -> Vector3:
|
|
var m := to_world(Vector3(0, 0, PASSAGE_END + 1.2))
|
|
m.y = terrain.sample_height(m.x, m.z)
|
|
return m
|
|
|
|
# --- guards ---------------------------------------------------------------------------
|
|
|
|
func is_night() -> bool:
|
|
var h: float = world.day_night.hours()
|
|
return h >= 19.5 or h < 5.5
|
|
|
|
## Whether being where `p` is wakes the guards right now.
|
|
func wakes_for(p: Node3D) -> bool:
|
|
if alarmed:
|
|
return true
|
|
if p.survival.dead:
|
|
return false
|
|
var near := Vector2(p.global_position.x - position.x, p.global_position.z - position.z).length() < RING_R
|
|
if near and (is_night() or world.day_night.day > WAKE_DAY):
|
|
alarmed = true
|
|
return alarmed
|
|
|
|
func alarm() -> void:
|
|
alarmed = true
|
|
|
|
func guards_alive() -> Array:
|
|
return guards.filter(func(g): return is_instance_valid(g) and not g.is_dead())
|
|
|
|
## Starts the guards (on the machine that runs creatures).
|
|
func start() -> void:
|
|
if not found:
|
|
return
|
|
running = true
|
|
_spawn_guards()
|
|
|
|
func _spawn_guards() -> void:
|
|
for g in guards:
|
|
if is_instance_valid(g) and not g.is_dead():
|
|
g.queue_free()
|
|
guards.clear()
|
|
for i in range(POSTS.size()):
|
|
if fallen.has(i):
|
|
continue
|
|
var g: AnimatableBody3D = GuardScript.new()
|
|
var post: Vector3 = to_world(POSTS[i][0])
|
|
g.setup("guard", world, post, terrain.noise_seed + 600 + i)
|
|
g.barrow = self
|
|
g.post = Vector3(post.x, position.y + 0.03, post.z)
|
|
# Facing the mouth (+Z local); a creature's forward is -Z, so turn by PI.
|
|
g.post_yaw = yaw + PI + float(POSTS[i][1])
|
|
g.state = "rest"
|
|
g.yaw = g.post_yaw
|
|
g.rotation.y = g.yaw
|
|
g.set_meta("post", i)
|
|
g.died.connect(_on_guard_died)
|
|
g.top_level = true # (world space: the barrow is turned, and a creature's facing is its own rotation)
|
|
add_child(g)
|
|
g.global_position = g.post
|
|
guards.append(g)
|
|
|
|
func _on_guard_died(g: Node) -> void:
|
|
var i := int(g.get_meta("post", -1))
|
|
if i >= 0 and not fallen.has(i):
|
|
fallen.append(i)
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
if running:
|
|
step()
|
|
|
|
func step() -> void:
|
|
# Everyone's gone: the guards settle (they walk back to their posts).
|
|
if alarmed:
|
|
var anyone := false
|
|
for p in world.players():
|
|
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
|
|
anyone = true
|
|
if not anyone:
|
|
alarmed = false
|
|
# The coffin fills again (and the fallen are back) a few days after it
|
|
# was emptied, at dawn, with nobody about.
|
|
if loot_day >= 0 and world.day_night.day >= loot_day + RESTOCK_DAYS:
|
|
var h: float = world.day_night.hours()
|
|
if h >= 6.0 and h < 10.0:
|
|
for p in world.players():
|
|
if p.global_position.distance_to(position) < 30.0:
|
|
return
|
|
loot_day = -1
|
|
fallen.clear()
|
|
alarmed = false
|
|
_spawn_guards()
|
|
|
|
# --- the coffin ---------------------------------------------------------------------------
|
|
|
|
var _coffin: StaticBody3D
|
|
|
|
func stocked() -> bool:
|
|
return loot_day < 0
|
|
|
|
func days_to_restock() -> int:
|
|
return maxi(loot_day + RESTOCK_DAYS - int(world.day_night.day), 1)
|
|
|
|
## Opens the coffin for `player` (Actions.loot_barrow). Wakes the guards.
|
|
## Returns what was taken ({} if it was empty).
|
|
func loot(player: Node3D) -> Dictionary:
|
|
alarm()
|
|
if not stocked():
|
|
Actions.tell(player, "The coffin is empty. Come back in %d days." % days_to_restock())
|
|
return {}
|
|
var r := RandomNumberGenerator.new()
|
|
r.seed = terrain.noise_seed * 31 + int(world.day_night.day)
|
|
var got := {}
|
|
for id in LOOT:
|
|
got[id] = r.randi_range(int(LOOT[id][0]), int(LOOT[id][1]))
|
|
loot_day = int(world.day_night.day)
|
|
Actions.tell(player, "You lift the coffin lid... and something stirs behind you!")
|
|
return got
|
|
|
|
func coffin_prompt() -> String:
|
|
return "Stone coffin [E] Open"
|
|
|
|
func save_data() -> Dictionary:
|
|
return {"loot_day": loot_day, "fallen": fallen.duplicate()} if found else {}
|
|
|
|
func load_data(d: Dictionary) -> void:
|
|
if not found or d.is_empty():
|
|
return
|
|
loot_day = int(d.get("loot_day", -1))
|
|
fallen = []
|
|
for i in d.get("fallen", []):
|
|
fallen.append(int(i))
|
|
if running:
|
|
_spawn_guards()
|
|
|
|
class Coffin extends StaticBody3D:
|
|
var barrow: Node
|
|
func interact_prompt(_p: Node) -> String:
|
|
return barrow.coffin_prompt()
|
|
func interact(p: Node) -> void:
|
|
Actions.loot_barrow(p, barrow)
|