P5 the Old Stag: standing stones and altar, three-phase boss with charges, stun window, spirit wolves and stomps; antler pickaxe and trophy; group scaling and credit for every fighter; online 3-player test (protocol 12)

This commit is contained in:
Paul Bailey
2026-11-05 13:39:06 +00:00
parent 3389ec9a21
commit e03411ffdb
24 changed files with 1587 additions and 17 deletions

View File

@@ -65,6 +65,7 @@ func place(w: Node3D) -> bool:
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
@@ -364,6 +365,7 @@ func _spawn_guards() -> void:
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)

View File

@@ -545,6 +545,25 @@ func _scatter_bushes() -> void:
_group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y)))
_flush(groups, meshes, true, "Bushes")
## Hides the decorative bushes within `r` of `center` (the barrow's and the
## standing stones' clearings: they're placed after the undergrowth).
## Deterministic, like the rest of the scenery.
func clear_bushes(center: Vector3, r: float) -> int:
var root := get_node_or_null("Bushes")
if root == null:
return 0
var n := 0
for mmi in root.get_children():
if not mmi is MultiMeshInstance3D:
continue
var mm: MultiMesh = mmi.multimesh
for i in range(mm.instance_count):
var xf: Transform3D = mmi.global_transform * mm.get_instance_transform(i)
if Vector2(xf.origin.x - center.x, xf.origin.z - center.z).length() < r:
mm.set_instance_transform(i, Transform3D(xf.basis, xf.origin + Vector3(0, -1000, 0)))
n += 1
return n
func _place_logs() -> void:
var root := Node3D.new()
root.name = "Logs"

View File

@@ -0,0 +1,393 @@
extends Node3D
## The standing stones (docs/PROGRESSION_DESIGN.md §6): a ring of tall stones
## at the foot of the rim with an altar in the middle, built from the seed
## like the den and the barrow. The altar has three hollows for the barrow
## key pieces. With all three in, at night (19:30 to 05:30), E on the altar
## calls the Old Stag (old_stag.gd).
##
## - Lose (everyone leaves, dies, or dawn comes) and the stag goes back into
## the mist; the key pieces stay in the altar for another night.
## - Win and it drops its antler (for the antler pickaxe) and a trophy; the
## key pieces are used up, and the world remembers who beat it.
##
## The fight runs where the game decides (single-player, a server). Saved
## with the world: the key pieces in the altar and whether it's been beaten.
## Players online are sent the altar's state (world_net.gd _s2c_stones).
const IM = preload("res://scripts/items/item_meshes.gd")
const P = preload("res://scripts/world/proc_mesh.gd")
const StagScript = preload("res://scripts/creatures/old_stag.gd")
const CreatureScript = preload("res://scripts/creatures/creature.gd")
const RING_R := 7.5
const STONES := 7
const CLEAR_R := 10.0
const KEYS := 3
const LEASH := 45.0 ## nobody this near for LEAVE_AFTER: the stag goes
const LEAVE_AFTER := 5.0
var world: Node3D
var terrain: Node3D
var found := false
var yaw := 0.0
var keys := 0 ## key pieces in the altar
var beaten := false
var beaten_by := ""
var beaten_day := -1
var fighters: Array = [] ## (on a server) the names of everyone who fought it
var stag: Node3D = null
var wolves: Array = []
var deciding := true ## false on a player's game online
var stomps := 0
var _alone := 0.0
var _rng := RandomNumberGenerator.new()
var _key_meshes: Array = []
var _glow: OmniLight3D
var _altar: StaticBody3D
func place(w: Node3D) -> bool:
world = w
terrain = w.terrain
var site := _find_site()
if site == Vector3.INF:
push_warning("[stones] no site for the standing stones on this seed")
return false
found = true
position = site
yaw = atan2(-site.x, -site.z) # the altar faces the valley
rotation.y = yaw
_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), RING_R, RING_R])
return true
## At the foot of the rim, fairly flat, well away from the spawn, the den,
## the barrow and the bear's home. Rougher ground is accepted on later
## passes, so every island has one.
func _find_site() -> Vector3:
_rng.seed = terrain.noise_seed + 1501
var start := _rng.randf() * 360.0
var spawn: Vector3 = world._spawn
var avoid: Array = [[Vector2(spawn.x, spawn.z), 60.0]]
if world.den != null and world.den.found:
avoid.append([Vector2(world.den.position.x, world.den.position.z), 60.0])
if world.barrow != null and world.barrow.found:
avoid.append([Vector2(world.barrow.position.x, world.barrow.position.z), 50.0])
for pass_ in [[1.6, [6.0, 10.0, 14.0]], [2.6, [6.0, 10.0, 14.0, 18.0, 24.0, 30.0]], [4.0, [8.0, 16.0, 24.0, 32.0, 40.0]]]:
for step in range(60):
var ang := deg_to_rad(start + step * 6.0)
var dir := Vector2(cos(ang), sin(ang))
var r: float = terrain.play_radius + 10.0
while r > terrain.play_radius - 50.0 and not terrain.inside_boundary(dir.x * r, dir.y * r, CLEAR_R + 2.0):
r -= 1.0
for inset: float in pass_[1]:
var c: Vector2 = dir * (r - inset)
if not terrain.inside_boundary(c.x, c.y, CLEAR_R + 1.0) or terrain.sea_weight(c.x, c.y) > 0.02:
continue
var far := true
for a in avoid:
if c.distance_to(a[0]) < float(a[1]):
far = false
if not far:
continue
var hs: Array[float] = []
for k in range(8):
var b := TAU * k / 8.0
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0:
continue
return Vector3(c.x, h0, c.y)
return Vector3.INF
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()
func _build(rock_mat: Material) -> void:
var body := StaticBody3D.new()
body.name = "Stones"
body.collision_layer = 1
add_child(body)
for i in range(STONES):
var a := TAU * i / STONES
var lp := Vector3(sin(a) * RING_R, 0, cos(a) * RING_R)
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
# Tall, narrow slabs (the rock mesh is a unit blob: stretch it up).
var radii := Vector3(0.55, 3.8 + 0.6 * sin(i * 2.7), 0.38)
var y: float = terrain.sample_height(w.x, w.z) - position.y + radii.y * 0.45 - 0.3
var mesh := P.rock(terrain.noise_seed + 6100 + i, false)
var turn := Basis(Vector3.UP, a) * Basis(Vector3.RIGHT, 0.04 * sin(i * 1.9))
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)
# The altar: a low slab on two blocks, with three hollows on top.
_altar = Altar.new()
_altar.stones = self
_altar.name = "Altar"
_altar.collision_layer = 1
add_child(_altar)
var st := P._begin()
var stone := Color(0.46, 0.46, 0.44)
var r := RandomNumberGenerator.new()
r.seed = terrain.noise_seed + 1502
for sx in [-0.55, 0.55]:
IM._box(st, Vector3(sx, 0.3, 0), Vector3(0.4, 0.6, 0.7), P._mul(stone, r.randf_range(0.85, 1.0)))
IM._box(st, Vector3(0, 0.7, 0), Vector3(1.8, 0.2, 1.0), stone)
for i in range(KEYS):
IM._box(st, Vector3(-0.5 + 0.5 * i, 0.805, 0), Vector3(0.24, 0.01, 0.24), Color(0.16, 0.16, 0.16))
var mi := MeshInstance3D.new()
mi.mesh = P._commit(st)
mi.material_override = ItemDB.item_material()
_altar.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.8, 0.8, 1.0)
cs.shape = box
cs.position = Vector3(0, 0.4, 0)
_altar.add_child(cs)
for i in range(KEYS):
var k := MeshInstance3D.new()
k.mesh = ItemDB.mesh_for("barrow_key")
k.material_override = ItemDB.item_material()
k.position = Vector3(-0.5 + 0.5 * i, 0.8, 0.08)
k.rotation.y = PI
k.visible = false
_altar.add_child(k)
_key_meshes.append(k)
if DisplayServer.get_name() != "headless":
_glow = OmniLight3D.new()
_glow.light_color = Color(0.55, 0.9, 1.0)
_glow.omni_range = 9.0
_glow.light_energy = 0.0
_glow.position = Vector3(0, 1.6, 0)
add_child(_glow)
_show()
## The key pieces in the altar, and a pale glow once all three are in.
func _show() -> void:
for i in range(_key_meshes.size()):
_key_meshes[i].visible = i < keys
if _glow != null:
_glow.light_energy = 1.8 if keys >= KEYS else 0.3 * keys
func center() -> Vector3:
return position
func altar() -> Node3D:
return _altar
func is_night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
func fighting() -> bool:
return stag != null and is_instance_valid(stag) and not stag.is_dead()
# --- the altar --------------------------------------------------------------------------
func altar_prompt() -> String:
if fighting():
return "The altar (the Old Stag is here!)"
if keys < KEYS:
return "Stone altar: %d of %d hollows filled [E] Set a barrow key piece" % [keys, KEYS]
if is_night():
return "Stone altar, humming [E] Call the Old Stag"
return "Stone altar, humming (come back at night)"
## E on the altar (Actions.use_stones): set key pieces in it, or call the
## stag. Returns what happened: "set", "summoned", "" (nothing).
func use(player: Node3D) -> String:
if fighting():
return ""
if keys < KEYS:
var have: int = player.inventory.count("barrow_key")
if have <= 0:
Actions.tell(player, "Three hollows in the stone (%d filled). Barrow key pieces would fit them." % keys)
return ""
var n := mini(have, KEYS - keys)
player.inventory.remove("barrow_key", n)
keys += n
_changed()
if keys < KEYS:
Actions.tell(player, "You set a key piece in the altar (%d of %d)." % [keys, KEYS])
else:
Actions.tell(player, "The last key piece clicks into place. The stones begin to hum%s." % ("" if is_night() else ": come back at night"))
return "set"
if not is_night():
Actions.tell(player, "The stones hum, waiting for the dark.")
return ""
summon(player)
return "summoned"
## Calls the Old Stag, on the far side of the ring from `player`.
func summon(player: Node3D) -> Node3D:
var away := position - player.global_position
away.y = 0.0
away = away.normalized() if away.length() > 0.1 else Vector3(1, 0, 0)
var at := _inside_point(away, RING_R + 6.0)
var s: AnimatableBody3D = StagScript.new()
s.setup("old_stag", world, at, _rng.randi())
s.stones = self
s.state = "summon"
s._state_t = 2.5
var to: Vector3 = player.global_position - at
s.yaw = atan2(-to.x, -to.z)
s.rotation.y = s.yaw
s.top_level = true # (in world space: the stones are moved and turned, a creature steers in world space)
add_child(s)
s.scale_for(group_size())
stag = s
_alone = 0.0
world.weather.flash = 0.8
Actions.tell_all("The standing stones wake... the Old Stag has come!")
return s
## How many (living) players are at the stones: the stag's toughened for
## them, and calls a wolf more for each.
func group_size() -> int:
var n := 0
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
n += 1
return maxi(n, 1)
## The stag calls `n` spirit wolves out of the mist round the ring (and one
## more for each extra player, up to 4 more).
func summon_wolves(s: Node3D, n: int) -> void:
n += mini(group_size() - 1, 4)
for i in range(n):
var a := _rng.randf() * TAU
var at := _inside_point(Vector3(cos(a), 0, sin(a)), RING_R + 4.0)
var w: AnimatableBody3D = CreatureScript.new()
w.setup("spirit_wolf", world, at, _rng.randi())
w.state = "charge"
w._aggro = 9999.0
w.top_level = true
add_child(w)
wolves.append(w)
var who: Node = world.nearest_player(s.global_position)
if who != null:
Actions.tell(who, "The Old Stag bellows, and spirit wolves come out of the mist!")
## A point `r` from the centre roughly along `dir`, turned until it's inside
## the valley with room to move (the stones are at the foot of the rim).
func _inside_point(dir: Vector3, r: float) -> Vector3:
for turn in [0.0, 0.5, -0.5, 1.0, -1.0, 1.6, -1.6, 2.2, -2.2, PI]:
var p: Vector3 = position + dir.rotated(Vector3.UP, turn) * r
if terrain.inside_boundary(p.x, p.z, 4.0) and terrain.sample_height(p.x, p.z) > terrain.water_level + 0.3:
return p
return position + dir * 3.0
func stomped(_s: Node3D) -> void:
stomps += 1
world.weather.flash = maxf(world.weather.flash, 0.3)
func wolves_alive() -> Array:
return wolves.filter(func(w): return is_instance_valid(w) and not w.is_dead())
func _physics_process(_delta: float) -> void:
if deciding:
step(_delta)
## Sends the stag (and its wolves) back into the mist when everyone's gone
## or dead, or at dawn.
func step(dt: float) -> void:
if not fighting():
return
var anyone := false
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
anyone = true
_alone = 0.0 if anyone else _alone + dt
if _alone >= LEAVE_AFTER or not is_night():
fade("dawn" if not is_night() else "gone")
func fade(why: String) -> void:
if stag != null and is_instance_valid(stag):
stag.queue_free()
stag = null
_clear_wolves()
Actions.tell_all("The Old Stag fades into the mist%s. The key pieces are still in the altar." % (" with the dawn" if why == "dawn" else ""))
func _clear_wolves() -> void:
for w in wolves:
if is_instance_valid(w):
w.queue_free()
wolves.clear()
## Beaten: its antler and a trophy where it fell, the key pieces used up,
## and the world remembers.
func stag_died(s: Node3D) -> void:
var at: Vector3 = s.global_position + Vector3(0, 0.4, 0)
world.drop_item("stag_antler", 1, Transform3D(Basis(), at))
world.drop_item("stag_trophy", 1, Transform3D(Basis(), at + Vector3(1.2, 0, 0.6)))
_clear_wolves()
keys = 0
var names: Array = s.fighter_names()
beaten = true
beaten_by = join_names(names) if world.server_mode else "you"
fighters = names.duplicate() if world.server_mode else []
beaten_day = int(world.day_night.day)
stag = null
_changed()
if world.server_mode:
Actions.tell_all("The Old Stag has fallen! Beaten by %s on day %d." % [beaten_by if not names.is_empty() else "the island", beaten_day])
else:
Actions.tell_all("The Old Stag has fallen! Its antler lies where it fell.")
## "Ana", "Ana and Bob", "Ana, Bob and Cara".
static func join_names(names: Array) -> String:
if names.is_empty():
return ""
if names.size() == 1:
return String(names[0])
return ", ".join(PackedStringArray(names.slice(0, names.size() - 1))) + " and " + String(names.back())
func _changed() -> void:
_show()
if world != null and world.server_mode:
WorldNet.stones_state(save_data())
func save_data() -> Dictionary:
if not found:
return {}
return {"keys": keys, "beaten": beaten, "beaten_by": beaten_by, "beaten_day": beaten_day, "fighters": fighters.duplicate()}
func load_data(d: Dictionary) -> void:
if not found or d.is_empty():
return
keys = clampi(int(d.get("keys", 0)), 0, KEYS)
beaten = bool(d.get("beaten", false))
beaten_by = String(d.get("beaten_by", ""))
beaten_day = int(d.get("beaten_day", -1))
fighters = (d.get("fighters", []) as Array).duplicate()
_show()
class Altar extends StaticBody3D:
var stones: Node
func interact_prompt(_p: Node) -> String:
return stones.altar_prompt()
func interact(p: Node) -> void:
Actions.use_stones(p, stones)

View File

@@ -67,6 +67,7 @@ var wolves: Node3D ## wolf_pack.gd (null if this seed has no den si
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does)
var net_sync: Node ## net_sync.gd when playing on a server, else null
@@ -352,6 +353,12 @@ func _on_terrain_ready() -> void:
add_child(barrow)
if barrow.place(self):
_update_grass_cuts()
# The standing stones (P5): where the Old Stag is called.
stones = preload("res://scripts/world/standing_stones.gd").new()
stones.name = "StandingStones"
add_child(stones)
if stones.place(self):
_update_grass_cuts()
if DisplayServer.get_name() != "headless":
var fish := preload("res://scripts/fishing/lake_fish.gd").new()
fish.name = "LakeFish"
@@ -366,6 +373,7 @@ func _on_terrain_ready() -> void:
hazards.name = "Hazards"
add_child(hazards)
hazards.setup(self, not is_online())
stones.deciding = not is_online()
if server_mode:
if Saves.has_world():
var saved: Dictionary = Saves.load_state(Saves.current_id)
@@ -601,6 +609,7 @@ func collect_save() -> Dictionary:
"pieces": pieces,
"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 {},
}
func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
@@ -614,6 +623,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop
"pieces": pieces,
"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 {},
}
## The live pieces (placed and not being removed).
@@ -742,6 +752,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
props.restore_regrow(s.get("regrow", {}))
if barrow != null:
barrow.load_data(s.get("barrow", {}))
if stones != null:
stones.load_data(s.get("stones", {}))
# The bear (bear_range.gd): applied when it's set up, after loading.
_saved_bear = s.get("bear", {})
if bears != null: