Files
Bullosseum/bull_legs.gd
T
2026-05-25 18:37:42 +03:00

462 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node
## Procedural leg stepping, body wobble, and tail simulation.
## Created at runtime by player.gd; call setup() before the first _process tick.
# ── Leg State Machine ─────────────────────────────────────────────────────────
enum LegState { GALLOP, LAUNCH, RAGDOLL, TUCK, LAND }
# Leg IK config — rest offsets are in bull_v10 local space
const LEG_CFG: Dictionary = {
"FL": { "root": &"front_leg_1_L", "tip": &"front_leg_2_L_end",
"rest": Vector3(-0.30, -0.25, -0.50) },
"FR": { "root": &"front_leg_1_R", "tip": &"front_leg_2_R_end",
"rest": Vector3( 0.30, -0.25, -0.50) },
"BL": { "root": &"rear_leg_1_L", "tip": &"rear_leg_3_L_end",
"rest": Vector3(-0.30, -0.25, 0.60) },
"BR": { "root": &"rear_leg_1_R", "tip": &"rear_leg_3_R_end",
"rest": Vector3( 0.30, -0.25, 0.60) },
}
# Gallop gait phase offset per leg (0.01.0 cycle).
const LEG_PHASES: Dictionary = { "BL": 0.0, "FL": 0.2, "BR": 0.5, "FR": 0.7 }
# Per-leg ragdoll phase offsets for variety
const RAGDOLL_PHASE: Dictionary = { "FL": 0.0, "FR": 1.3, "BL": 2.6, "BR": 3.9 }
var _player: CharacterBody3D
var _bull: Node3D
var _skeleton: Skeleton3D
# Per-leg state
var _ik: Dictionary = {} # id -> SkeletonIK3D
var _foot: Dictionary = {} # id -> current foot (world)
var _step_from: Dictionary = {} # id -> Vector3
var _step_to: Dictionary = {} # id -> Vector3
var _step_t: Dictionary = {} # id -> float (1.0 = planted)
# Body wobble
var _prev_vel: Vector3 = Vector3.ZERO
var _tilt_x: float = 0.0
var _tilt_z: float = 0.0
var _aerial_pitch: float = 0.0
# Leg state machine
var _leg_state: LegState = LegState.GALLOP
var _state_time: float = 0.0 # time spent in current state
var _was_on_floor: bool = true
var _launch_feet: Dictionary = {} # frozen foot positions at launch
# Gait
var _gait_phase: float = 0.0
# Tail — Verlet chain simulation
const TAIL_BONES: Array = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
&"tail_5", &"tail_6",
&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
]
var _tail_idx: Array[int] = []
var _tail_world: Array[Vector3] = []
var _tail_prev: Array[Vector3] = []
var _tail_lengths: Array[float] = []
var _tail_rest_dir: Array[Vector3] = []
func setup(player: CharacterBody3D, bull: Node3D) -> void:
_player = player
_bull = bull
_skeleton = _find_skeleton(bull)
if not _skeleton:
push_error("BullLegs: Skeleton3D not found under bull_v10")
return
# Disable any physical bone simulator — we don't need it
var phys_sim := _find_physical_bones(_skeleton)
if phys_sim:
phys_sim.active = false
_setup_tail()
for id: String in LEG_CFG:
var cfg: Dictionary = LEG_CFG[id]
var ik := SkeletonIK3D.new()
ik.root_bone = cfg["root"]
ik.tip_bone = cfg["tip"]
ik.interpolation = 1.0
ik.min_distance = 0.01
_skeleton.add_child(ik)
ik.start()
_ik[id] = ik
var world_rest: Vector3 = _bull.to_global(cfg["rest"])
_foot[id] = world_rest
_step_from[id] = world_rest
_step_to[id] = world_rest
_step_t[id] = 1.0
func _setup_tail() -> void:
for bone_name: StringName in TAIL_BONES:
var idx: int = _skeleton.find_bone(bone_name)
if idx == -1:
push_warning("BullLegs: tail bone '%s' not found" % bone_name)
continue
_tail_idx.append(idx)
if _tail_idx.size() < 2:
push_error("BullLegs: not enough tail bones found")
return
# Initialise world positions first
for idx: int in _tail_idx:
var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
_tail_world.append(world)
_tail_prev.append(world)
# Detect degenerate rest pose (FBX bones all at same position)
var max_sep: float = 0.0
for i in range(_tail_world.size() - 1):
max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
if max_sep < 0.01:
push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
var root: Vector3 = _tail_world[0]
# Backward-downward direction in world space from the skeleton
var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
for i in range(_tail_world.size()):
_tail_world[i] = root + back * (i * 0.09)
_tail_prev[i] = _tail_world[i]
# Segment lengths in world space — used by the Verlet length constraint
const MIN_SEG: float = 0.06
for i in range(_tail_world.size() - 1):
var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
_tail_lengths.append(maxf(seg_len, MIN_SEG))
# Rest directions in SKELETON-LOCAL space — must match point_dir_skel in _apply_tail
# so that Quaternion(rest_dir, point_dir_skel) operates in a consistent frame.
# _tail_world holds world positions; to_local converts them to skeleton space.
for i in range(_tail_world.size() - 1):
var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
var seg_skel: Vector3 = p2_skel - p1_skel
_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
_tail_rest_dir.append(_tail_rest_dir[-1])
func _process(delta: float) -> void:
if not _skeleton:
return
_wobble_body(delta)
_update_leg_state(delta)
_step_feet(delta)
_apply_ik()
_update_tail(delta)
_apply_tail()
# ── Body Wobble ───────────────────────────────────────────────────────────────
func _wobble_body(delta: float) -> void:
var vel: Vector3 = _player.velocity
var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
_prev_vel = vel
var la: Vector3 = _bull.global_transform.basis.inverse() * accel
var tx := clampf(-la.z * DP.f("body_tilt_fwd"), -0.35, 0.35)
var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)
_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)
var flat_spd: float = Vector2(vel.x, vel.z).length()
var target_pitch: float = 0.0
if not _player.is_on_floor() and flat_spd > 0.5:
target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))
_bull.rotation.x = _tilt_x + _aerial_pitch
_bull.rotation.z = _tilt_z
# ── Leg State Machine ─────────────────────────────────────────────────────────
func _update_leg_state(delta: float) -> void:
var on_floor: bool = _player.is_on_floor()
_state_time += delta
match _leg_state:
LegState.GALLOP:
if not on_floor and _was_on_floor:
# Just left the ground — enter LAUNCH
_leg_state = LegState.LAUNCH
_state_time = 0.0
# Freeze current foot positions
for id: String in _foot:
_launch_feet[id] = _foot[id]
LegState.LAUNCH:
if on_floor:
_leg_state = LegState.GALLOP
_state_time = 0.0
elif _state_time > DP.f("launch_hold_time"):
_leg_state = LegState.RAGDOLL
_state_time = 0.0
LegState.RAGDOLL:
if on_floor:
_leg_state = LegState.GALLOP
_state_time = 0.0
elif _player.velocity.y < DP.f("tuck_apex_threshold"):
# Approaching or past apex — start tucking
_leg_state = LegState.TUCK
_state_time = 0.0
LegState.TUCK:
if on_floor:
_leg_state = LegState.GALLOP
_state_time = 0.0
else:
# Check for ground proximity — transition to LAND
var space := _player.get_world_3d().direct_space_state
var start := _player.global_position
var end_pt := start + Vector3.DOWN * DP.f("jump_land_threshold")
var params := PhysicsRayQueryParameters3D.create(start, end_pt)
params.exclude = [_player.get_rid()]
var hit := space.intersect_ray(params)
if hit:
_leg_state = LegState.LAND
_state_time = 0.0
LegState.LAND:
if on_floor:
_leg_state = LegState.GALLOP
_state_time = 0.0
_was_on_floor = on_floor
# ── Feet Stepping ─────────────────────────────────────────────────────────────
func _step_feet(delta: float) -> void:
match _leg_state:
LegState.GALLOP:
_step_gallop(delta)
LegState.LAUNCH:
_step_launch(delta)
LegState.RAGDOLL:
_step_ragdoll(delta)
LegState.TUCK:
_step_tuck(delta)
LegState.LAND:
_step_land(delta)
func _step_gallop(delta: float) -> void:
var threshold: float = DP.f("leg_step_threshold")
var duration: float = DP.f("leg_step_duration")
var height: float = DP.f("leg_step_height")
var overshoot: float = DP.f("leg_step_overshoot")
var flat_spd: float = Vector2(_player.velocity.x, _player.velocity.z).length()
var prev_phase: float = _gait_phase
_gait_phase = fmod(_gait_phase + flat_spd * DP.f("gait_freq") * delta, 1.0)
# Advance in-progress steps
for id: String in _step_t:
if _step_t[id] >= 1.0:
continue
_step_t[id] = minf(_step_t[id] + delta / duration, 1.0)
var t: float = _step_t[id]
var e: float = t * t * (3.0 - 2.0 * t)
var arc: float = height * sin(t * PI)
_foot[id] = _step_from[id].lerp(_step_to[id], e) + Vector3(0.0, arc, 0.0)
# Trigger steps when gait phase crosses each leg's offset
for id: String in LEG_PHASES:
if _step_t[id] < 1.0:
continue
if not _phase_crossed(prev_phase, _gait_phase, LEG_PHASES[id]):
continue
var ideal: Vector3 = _ideal_foot(id)
if _foot[id].distance_to(ideal) < threshold:
continue
_step_from[id] = _foot[id]
_step_to[id] = ideal + _player.velocity * duration * overshoot
_step_t[id] = 0.0
func _step_launch(_delta: float) -> void:
# Hold feet at their frozen launch positions
for id: String in _foot:
if _launch_feet.has(id):
_foot[id] = _launch_feet[id]
func _step_ragdoll(_delta: float) -> void:
# Procedural floppy dangle — NOT real physics ragdoll
var time: float = _state_time
var amp: float = DP.f("ragdoll_wobble_amp")
# Amplitude grows over time (legs spread more the longer airborne)
var grow: float = clampf(time * 0.8, 0.0, 1.5)
for id: String in _foot:
var rest: Vector3 = LEG_CFG[id]["rest"]
var phase: float = RAGDOLL_PHASE[id]
var dangle_offset := Vector3(
sin(time * 3.0 + phase) * amp * grow, # side sway
-0.4 - sin(time * 2.0 + phase) * 0.1 * grow, # gravity pull down
cos(time * 2.5 + phase) * amp * grow # forward/back wobble
)
_foot[id] = _bull.to_global(rest + dangle_offset)
func _step_tuck(delta: float) -> void:
# Smoothly pull legs into tucked position under the body
var speed: float = DP.f("jump_tuck_speed")
for id: String in _foot:
var tucked: Vector3 = _get_tucked_foot(id)
_foot[id] = _foot[id].lerp(tucked, clampf(delta * speed, 0.0, 1.0))
func _step_land(delta: float) -> void:
# Extend legs from tucked pose toward ideal ground positions, resume gallop cycle
var speed: float = DP.f("jump_tuck_speed") * 1.5 # slightly faster extend
for id: String in _foot:
var ideal: Vector3 = _ideal_foot(id)
_foot[id] = _foot[id].lerp(ideal, clampf(delta * speed, 0.0, 1.0))
# ── Helpers ───────────────────────────────────────────────────────────────────
func _phase_crossed(prev: float, curr: float, target: float) -> bool:
if curr >= prev:
return target >= prev and target < curr
else:
return target >= prev or target < curr
func _ideal_foot(id: String) -> Vector3:
var world_rest: Vector3 = _bull.to_global(LEG_CFG[id]["rest"])
# Raycast for ground
var space := _player.get_world_3d().direct_space_state
var params := PhysicsRayQueryParameters3D.create(
world_rest + Vector3(0.0, 0.6, 0.0),
world_rest + Vector3(0.0, -1.4, 0.0),
)
params.exclude = [_player.get_rid()]
var hit := space.intersect_ray(params)
return hit["position"] if hit else world_rest
func _get_tucked_foot(id: String) -> Vector3:
var rest: Vector3 = LEG_CFG[id]["rest"]
var tucked_local := rest + Vector3(0.0, DP.f("jump_tuck_up"), DP.f("jump_tuck_fwd"))
return _bull.to_global(tucked_local)
func _apply_ik() -> void:
var skel_inv: Transform3D = _skeleton.global_transform.affine_inverse()
for id: String in _ik:
var ik: SkeletonIK3D = _ik[id]
ik.target = Transform3D(Basis.IDENTITY, skel_inv * _foot[id])
# ── Tail ──────────────────────────────────────────────────────────────────────
func _update_tail(delta: float) -> void:
if _tail_world.is_empty():
return
var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)
# Re-anchor root joint
var anchor: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
_tail_world[0] = anchor
_tail_prev[0] = anchor
# Idle wag force
var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
var time: float = Time.get_ticks_msec() / 1000.0
# Verlet integrate
for i in range(1, _tail_world.size()):
var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
_tail_prev[i] = _tail_world[i]
# Add wag force (sinusoidal side-to-side)
var wag := _bull.global_transform.basis.x \
* sin(time * DP.f("tail_wag_freq") + i * 0.5) * wag_strength
_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta
# Length constraint
for i in range(1, _tail_world.size()):
var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
var len: float = dir.length()
var target_len: float = _tail_lengths[i - 1]
if len > 0.0001:
_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
else:
_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)
# Body collision pass — prevent tail from clipping through torso
# Use bull's local space; body is approximated as a capsule along Z axis
var body_radius: float = DP.f("tail_body_radius")
for i in range(1, _tail_world.size()):
var p := _bull.to_local(_tail_world[i])
# Capsule centered at roughly (0, 0, 0.1) in bull local space, half-length 0.4 along Z
var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
var diff := p - closest
var dist := diff.length()
if dist < body_radius and dist > 0.001:
p = closest + diff.normalized() * body_radius
_tail_world[i] = _bull.to_global(p)
func _apply_tail() -> void:
if _tail_world.is_empty():
return
for i in range(_tail_world.size()):
var idx: int = _tail_idx[i]
var pos_skel: Vector3 = _skeleton.to_local(_tail_world[i])
var point_dir_skel: Vector3
if i < _tail_world.size() - 1:
point_dir_skel = (_skeleton.to_local(_tail_world[i + 1]) - pos_skel).normalized()
else:
var prev_skel: Vector3 = _skeleton.to_local(_tail_world[i - 1])
point_dir_skel = (pos_skel - prev_skel).normalized()
var rest_dir: Vector3 = _tail_rest_dir[i]
var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
var new_basis: Basis
if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
else:
new_basis = rest_t.basis
_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
for child in node.get_children():
if child is PhysicalBoneSimulator3D:
return child as PhysicalBoneSimulator3D
return null