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:
@@ -180,8 +180,17 @@ func _desired_speed() -> float:
|
||||
"charge", "flee": return def.run
|
||||
return 0.0
|
||||
|
||||
## (The chest it's going for isn't in its way: it walks right up to it.)
|
||||
## (The chest it's going for isn't in its way: it walks right up to it. Nor
|
||||
## is the person it's charging: it runs into them, not round them.)
|
||||
func _blocked(dir: Vector3) -> bool:
|
||||
if state == "charge" and player != null and global_position.distance_to(player.global_position) < 6.0:
|
||||
var probe := global_position + dir * (1.4 + speed * 0.15)
|
||||
var h: float = terrain.sample_height(probe.x, probe.z)
|
||||
if not terrain.inside_boundary(probe.x, probe.z, 6.0) or h < terrain.water_level + 0.2 or absf(h - global_position.y) > 1.1:
|
||||
return true
|
||||
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.45, 0), Vector3(probe.x, h + 0.45, probe.z), 1)
|
||||
q.exclude = [get_rid(), player.get_rid(), terrain.get_node("TerrainBody").get_rid()]
|
||||
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||||
if state == "raid" and food != null and is_instance_valid(food) and global_position.distance_to(food.global_position) < 3.5:
|
||||
var probe := global_position + dir * 1.4
|
||||
var h: float = terrain.sample_height(probe.x, probe.z)
|
||||
|
||||
@@ -63,6 +63,8 @@ func _find_home() -> bool:
|
||||
continue
|
||||
if world.barrow != null and world.barrow.found and Vector2(c.x - world.barrow.position.x, c.z - world.barrow.position.z).length() < 40.0:
|
||||
continue
|
||||
if world.stones != null and world.stones.found and Vector2(c.x - world.stones.position.x, c.z - world.stones.position.z).length() < 42.0:
|
||||
continue
|
||||
if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0:
|
||||
continue
|
||||
var near := 0
|
||||
|
||||
@@ -60,6 +60,14 @@ const KINDS := {
|
||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
|
||||
"drops": {"iron_nugget": [1, 2]}},
|
||||
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
|
||||
# spirit wolves it summons (they just run at you: the base "charge").
|
||||
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
|
||||
"notice": 0.0, "notice_loud": 0.0, "calm": 60.0,
|
||||
"damage": 30.0, "attack_range": 2.8, "attack_cd": 1.0, "drops": {}},
|
||||
"spirit_wolf": {"name": "Spirit Wolf", "hp": 40.0, "walk": 1.6, "run": 7.4, "turn": 6.0,
|
||||
"notice": 0.0, "notice_loud": 0.0, "calm": 60.0,
|
||||
"damage": 8.0, "attack_range": 1.8, "attack_cd": 1.3, "aggro": 9999.0, "drops": {}},
|
||||
# One bear per island, in the deep forest by the rim (bear.gd, bear_range.gd).
|
||||
# Slower than you sprinting, but it hits like a falling tree.
|
||||
"bear": {"name": "Bear", "hp": 220.0, "walk": 1.3, "run": 7.0, "turn": 3.5,
|
||||
@@ -132,7 +140,8 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
||||
var eyes := MeshInstance3D.new()
|
||||
eyes.mesh = parts.eyes
|
||||
var em := StandardMaterial3D.new()
|
||||
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
||||
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "old_stag": Color(0.55, 0.95, 1.0),
|
||||
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
||||
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
|
||||
em.emission_enabled = true
|
||||
em.emission = eye
|
||||
@@ -157,7 +166,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
||||
cs.position = Vector3(0, parts.height * 0.5, 0)
|
||||
if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright)
|
||||
# Long bodies: lay the capsule along the spine.
|
||||
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3}.get(k, 1.4)
|
||||
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
|
||||
cs.rotation.x = PI * 0.5
|
||||
cs.position = Vector3(0, parts.height * 0.55, 0)
|
||||
add_child(cs)
|
||||
@@ -411,7 +420,7 @@ func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
|
||||
|
||||
## How a living animal responds to being hit (wolves override this).
|
||||
func _react(from: Vector3) -> void:
|
||||
if kind == "boar" and hp > def.hp * 0.3:
|
||||
if (kind == "boar" and hp > def.hp * 0.3) or kind == "spirit_wolf": # (spirit wolves never run)
|
||||
state = "charge"
|
||||
_aggro = def.aggro
|
||||
_attack_cd = 0.4
|
||||
|
||||
@@ -23,6 +23,8 @@ static func build(kind: String) -> Dictionary:
|
||||
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
|
||||
"crow": d = _crow()
|
||||
"guard": d = _guard()
|
||||
"old_stag": d = _scaled(_stag(), 2.1)
|
||||
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
|
||||
_: d = _boar()
|
||||
_cache[kind] = d
|
||||
return d
|
||||
@@ -250,3 +252,39 @@ static func _guard() -> Dictionary:
|
||||
return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst),
|
||||
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
||||
"radius": 0.3, "height": 1.85}
|
||||
|
||||
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
|
||||
## huge crown of antlers.
|
||||
static func _stag() -> Dictionary:
|
||||
var coat := Color(0.72, 0.74, 0.7)
|
||||
var moss := Color(0.32, 0.42, 0.24)
|
||||
var bone := Color(0.9, 0.87, 0.76)
|
||||
var dark := Color(0.15, 0.16, 0.18)
|
||||
var st := P._begin()
|
||||
var r := _rng(341)
|
||||
var n := _noise(341, 1.2)
|
||||
P._blob(st, Vector3(0, 1.0, 0), Vector3(0.3, 0.33, 0.66), n, 0.14, coat, r, 1)
|
||||
P._blob(st, Vector3(0, 1.18, -0.3), Vector3(0.3, 0.28, 0.32), n, 0.3, moss, r, 1) # mossy mane
|
||||
P._cylinder(st, Vector3(0, 1.15, -0.45), Vector3(0, 1.6, -0.68), 0.15, 0.1, 6, coat, r)
|
||||
P._blob(st, Vector3(0, 1.66, -0.8), Vector3(0.12, 0.13, 0.22), n, 0.1, coat, r, 1)
|
||||
P._blob(st, Vector3(0, 1.6, -1.0), Vector3(0.05, 0.05, 0.05), n, 0.0, dark, r, 0)
|
||||
for s in [-1.0, 1.0]:
|
||||
P._cylinder(st, Vector3(s * 0.07, 1.74, -0.74), Vector3(s * 0.2, 1.85, -0.7), 0.04, 0.015, 4, coat, r)
|
||||
# Antlers: a beam with tines, wide and high.
|
||||
var base := Vector3(s * 0.05, 1.78, -0.78)
|
||||
var mid := Vector3(s * 0.32, 2.2, -0.7)
|
||||
var top := Vector3(s * 0.52, 2.6, -0.55)
|
||||
P._cylinder(st, base, mid, 0.035, 0.028, 5, bone, r)
|
||||
P._cylinder(st, mid, top, 0.028, 0.012, 5, bone, r)
|
||||
for t in [[mid, Vector3(s * 0.28, 2.45, -0.95)], [mid, Vector3(s * 0.55, 2.25, -0.85)],
|
||||
[top, Vector3(s * 0.62, 2.85, -0.7)], [top, Vector3(s * 0.75, 2.6, -0.45)], [base.lerp(mid, 0.3), Vector3(s * 0.12, 2.05, -1.05)]]:
|
||||
P._cylinder(st, t[0], t[1], 0.02, 0.006, 4, bone, r)
|
||||
# Moss hanging from the antlers.
|
||||
P._cylinder(st, mid, mid + Vector3(0, -0.25, 0), 0.03, 0.01, 4, moss, r)
|
||||
P._blob(st, Vector3(0, 1.1, 0.64), Vector3(0.06, 0.09, 0.05), n, 0.1, coat, r, 0)
|
||||
var est := P._begin()
|
||||
for s in [-1.0, 1.0]:
|
||||
P._blob(est, Vector3(s * 0.085, 1.7, -0.9), Vector3.ONE * 0.025, n, 0.0, Color(0.6, 0.95, 1.0), r, 0)
|
||||
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.9, 0.06, 0.04, coat, dark, 342),
|
||||
"hips": [Vector3(-0.15, 0.9, -0.44), Vector3(0.15, 0.9, -0.44), Vector3(-0.15, 0.92, 0.44), Vector3(0.15, 0.92, 0.44)],
|
||||
"radius": 0.42, "height": 1.8}
|
||||
|
||||
210
scripts/creatures/old_stag.gd
Normal file
210
scripts/creatures/old_stag.gd
Normal file
@@ -0,0 +1,210 @@
|
||||
extends "res://scripts/creatures/creature.gd"
|
||||
## The Old Stag (docs/PROGRESSION_DESIGN.md §6): the island's one boss, called
|
||||
## by the standing stones (standing_stones.gd) at night with three barrow key
|
||||
## pieces. A huge antlered spirit.
|
||||
##
|
||||
## The fight goes round a loop: it circles you at a distance, stops and
|
||||
## lowers its antlers (the warning), then charges in a straight line through
|
||||
## where you were. Step aside and it thunders past; get caught and it hits for
|
||||
## 30. After a charge it stands stunned for a moment: hits then do half as
|
||||
## much again. Three phases by health:
|
||||
## - 1 (above 2/3): circle, charge, stunned 2.5 s.
|
||||
## - 2 (from 2/3): it rears and calls three spirit wolves out of the mist,
|
||||
## then goes on.
|
||||
## - 3 (from 1/3): two more wolves; quicker warnings and charges, a shorter
|
||||
## stun, and if you stay close it stamps the ground (15 to everyone within
|
||||
## 5.5 m).
|
||||
## It doesn't run, isn't afraid of fire, and goes back into the mist at dawn
|
||||
## or if everyone leaves the stones (the stones keep their key pieces).
|
||||
##
|
||||
## With more people at the stones when it's called it's tougher (60% more
|
||||
## health for each extra player; standing_stones.gd scale_for()), and it
|
||||
## remembers everyone who struck it (credit()): all of them are named when
|
||||
## it falls.
|
||||
|
||||
const CIRCLE_R := 14.0
|
||||
const CHARGE_HIT := 3.0 ## caught within this of its path
|
||||
const STUN_DAMAGE := 1.5 ## hits while stunned
|
||||
const STOMP_R := 5.5
|
||||
const STOMP_DAMAGE := 15.0
|
||||
const STOMP_CD := 4.0
|
||||
|
||||
var stones: Node ## standing_stones.gd
|
||||
var phase := 1
|
||||
var max_hp := 900.0 ## (scaled for the group when it's called)
|
||||
var fighters: Dictionary = {} ## who struck it: player id -> name, in order
|
||||
var _target := Vector3.ZERO ## where it's charging to
|
||||
var _dir := Vector3.FORWARD
|
||||
var _hit_this_charge := false
|
||||
var _stomp_cd := 0.0
|
||||
var _circle_a := 0.0
|
||||
var _goal_d := 0.0
|
||||
var charges := 0 ## (tests read these)
|
||||
var charge_hits := 0
|
||||
|
||||
func display_name() -> String:
|
||||
return def.name
|
||||
|
||||
func interact_prompt(_p: Node) -> String:
|
||||
if is_dead():
|
||||
return "The Old Stag (fading)"
|
||||
var tell := {"circle": "circling you", "telegraph": "lowering its antlers!", "charge": "CHARGING!",
|
||||
"stunned": "stunned: strike now!", "summon": "calling its wolves", "stomp": "rearing up!"}
|
||||
return "The Old Stag %d / %d (%s)" % [ceili(hp), int(max_hp), tell.get(state, "")]
|
||||
|
||||
func _pick_target() -> Node3D:
|
||||
return world.nearest_player(global_position)
|
||||
|
||||
func _phase_for(h: float) -> int:
|
||||
if h > max_hp * 2.0 / 3.0:
|
||||
return 1
|
||||
return 2 if h > max_hp / 3.0 else 3
|
||||
|
||||
## Tougher for a bigger group: `players` = how many were at the stones.
|
||||
func scale_for(players: int) -> void:
|
||||
max_hp = float(def.hp) * (1.0 + 0.6 * float(maxi(players, 1) - 1))
|
||||
hp = max_hp
|
||||
|
||||
## `who` struck it (Actions: a blow, an arrow or a thrown spear).
|
||||
func credit(who: Node) -> void:
|
||||
if who == null:
|
||||
return
|
||||
var id := String(who.get("player_id")) if who.get("player_id") != null else "you"
|
||||
if not fighters.has(id):
|
||||
fighters[id] = String(who.get("display")) if who.get("display") != null and not String(who.get("display")).is_empty() else "you"
|
||||
|
||||
## Everyone who struck it, by name, in the order they joined in.
|
||||
func fighter_names() -> Array:
|
||||
return fighters.values()
|
||||
|
||||
func _times() -> Dictionary:
|
||||
if phase >= 3:
|
||||
return {"circle": 2.0, "telegraph": 0.8, "stun": 1.5}
|
||||
return {"circle": 3.0, "telegraph": 1.2, "stun": 2.5}
|
||||
|
||||
func _think(dt: float) -> void:
|
||||
var pp: Vector3 = player.global_position
|
||||
_state_t -= dt
|
||||
_stomp_cd = maxf(_stomp_cd - dt, 0.0)
|
||||
var want := _phase_for(hp)
|
||||
if want > phase and state != "charge":
|
||||
phase = want
|
||||
state = "summon"
|
||||
_state_t = 2.0
|
||||
stones.summon_wolves(self, 3 if phase == 2 else 2)
|
||||
return
|
||||
match state:
|
||||
"summon":
|
||||
_face(pp)
|
||||
if _state_t <= 0.0:
|
||||
_start_circle()
|
||||
"circle":
|
||||
_circle_a += dt * 0.5
|
||||
var c := pp + Vector3(cos(_circle_a), 0, sin(_circle_a)) * CIRCLE_R
|
||||
_go_to(c)
|
||||
if phase >= 3 and global_position.distance_to(pp) < STOMP_R and _stomp_cd <= 0.0:
|
||||
state = "stomp"
|
||||
_state_t = 0.6
|
||||
elif _state_t <= 0.0:
|
||||
state = "telegraph"
|
||||
_state_t = _times().telegraph
|
||||
"telegraph":
|
||||
_face(pp)
|
||||
if _state_t <= 0.0:
|
||||
_dir = Vector3(pp.x - global_position.x, 0, pp.z - global_position.z).normalized()
|
||||
_target = pp + _dir * 7.0
|
||||
_hit_this_charge = false
|
||||
charges += 1
|
||||
state = "charge"
|
||||
_state_t = 4.0
|
||||
"charge":
|
||||
# (Checked every step along the way: anyone near its path is hit.)
|
||||
if not _hit_this_charge:
|
||||
for p in world.players():
|
||||
var d := Vector2(p.global_position.x - global_position.x, p.global_position.z - global_position.z).length()
|
||||
if d < CHARGE_HIT and not p.survival.dead:
|
||||
p.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "the Old Stag")
|
||||
_hit_this_charge = true
|
||||
charge_hits += 1
|
||||
var left := Vector2(_target.x - global_position.x, _target.z - global_position.z)
|
||||
if left.dot(Vector2(_dir.x, _dir.z)) <= 0.0 or _state_t <= 0.0:
|
||||
state = "stunned"
|
||||
_state_t = _times().stun
|
||||
"stunned":
|
||||
if _state_t <= 0.0:
|
||||
_start_circle()
|
||||
"stomp":
|
||||
_face(pp)
|
||||
if _state_t <= 0.0:
|
||||
_stomp_cd = STOMP_CD
|
||||
for p in world.players():
|
||||
if p.global_position.distance_to(global_position) < STOMP_R and not p.survival.dead:
|
||||
p.survival.damage(STOMP_DAMAGE * world.difficulty_mult("damage"), global_position, "the Old Stag")
|
||||
stones.stomped(self)
|
||||
_start_circle()
|
||||
_:
|
||||
_start_circle()
|
||||
|
||||
func _start_circle() -> void:
|
||||
state = "circle"
|
||||
_state_t = _times().circle
|
||||
var away := global_position - player.global_position
|
||||
_circle_a = atan2(away.z, away.x)
|
||||
|
||||
func _face(p: Vector3) -> void:
|
||||
var to := p - global_position
|
||||
target_yaw = atan2(-to.x, -to.z)
|
||||
_goal_d = 0.0
|
||||
|
||||
func _go_to(p: Vector3) -> void:
|
||||
var to := p - global_position
|
||||
to.y = 0.0
|
||||
if to.length() > 0.3:
|
||||
steer(atan2(-to.x, -to.z))
|
||||
_goal_d = to.length()
|
||||
|
||||
func _desired_speed() -> float:
|
||||
match state:
|
||||
"circle": return 0.0 if _goal_d < 1.0 else 6.5
|
||||
"charge": return def.run
|
||||
return 0.0
|
||||
|
||||
## A charge is a straight line: over the ground, through bushes and saplings
|
||||
## (it only stops for the edge of the valley and the water).
|
||||
func _move(dt: float) -> void:
|
||||
if state != "charge":
|
||||
super._move(dt)
|
||||
return
|
||||
speed = move_toward(speed, def.run, dt * def.run * 4.0)
|
||||
yaw = rotate_toward(yaw, atan2(-_dir.x, -_dir.z), 8.0 * dt)
|
||||
var p := global_position + _dir * speed * dt
|
||||
if not terrain.inside_boundary(p.x, p.z, 0.5) or terrain.sample_height(p.x, p.z) < terrain.water_level + 0.2:
|
||||
state = "stunned" # (pulled up short at the edge)
|
||||
_state_t = _times().stun
|
||||
return
|
||||
p.y = terrain.sample_height(p.x, p.z)
|
||||
global_position = p
|
||||
rotation.y = yaw
|
||||
|
||||
## Rearing for the summon and the stomp; head down for the warning.
|
||||
func _animate(dt: float) -> void:
|
||||
if state in ["summon", "stomp"]:
|
||||
_visual.rotation.x = lerpf(_visual.rotation.x, 0.5, minf(dt * 5.0, 1.0))
|
||||
return
|
||||
if state == "telegraph":
|
||||
_visual.rotation.x = lerpf(_visual.rotation.x, -0.25, minf(dt * 5.0, 1.0))
|
||||
return
|
||||
super._animate(dt)
|
||||
|
||||
func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
|
||||
super.take_hit(damage * (STUN_DAMAGE if state == "stunned" and not puppet else 1.0), from, embed_id)
|
||||
|
||||
## It doesn't flinch or run; it just keeps coming.
|
||||
func _react(_from: Vector3) -> void:
|
||||
pass
|
||||
|
||||
func _die(from: Vector3) -> void:
|
||||
super._die(from)
|
||||
carcass_left = 4.0 # (it fades back into the mist)
|
||||
if not puppet and stones != null:
|
||||
stones.stag_died(self)
|
||||
Reference in New Issue
Block a user