438 lines
15 KiB
GDScript
438 lines
15 KiB
GDScript
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.0–1.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
|
||
|
||
for i in range(_tail_idx.size() - 1):
|
||
var a: Transform3D = _skeleton.get_bone_global_rest(_tail_idx[i])
|
||
var b: Transform3D = _skeleton.get_bone_global_rest(_tail_idx[i + 1])
|
||
_tail_lengths.append((b.origin - a.origin).length())
|
||
_tail_rest_dir.append((b.origin - a.origin).normalized())
|
||
_tail_rest_dir.append(_tail_rest_dir[-1])
|
||
|
||
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)
|
||
|
||
|
||
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:
|
||
for id: String in _ik:
|
||
var ik: SkeletonIK3D = _ik[id]
|
||
var target_local: Vector3 = _skeleton.get_parent().to_local(_foot[id])
|
||
ik.target = Transform3D(Basis.IDENTITY, target_local)
|
||
|
||
|
||
# ── 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
|