bull legs not clipped, arena not scaled

This commit is contained in:
2026-05-25 19:39:02 +03:00
parent 80a35a4553
commit 30136a9b90
5 changed files with 117 additions and 278 deletions
+3 -3
View File
@@ -22,15 +22,15 @@ collision_layer = 3
script = ExtResource("1_xhfnw")
[node name="CollisionChest" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.2)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, -0.2)
shape = SubResource("SphereShape3D_chest")
[node name="CollisionBelly" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.1)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.1)
shape = SubResource("SphereShape3D_belly")
[node name="CollisionHips" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.4)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.4)
shape = SubResource("SphereShape3D_hips")
[node name="SpringArmPivot" type="Node3D" parent="." unique_id=1671817680]
+2 -259
View File
@@ -1,54 +1,17 @@
extends Node
## Procedural leg stepping, body wobble, and tail simulation.
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
## 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",
@@ -70,31 +33,13 @@ func setup(player: CharacterBody3D, bull: Node3D) -> void:
push_error("BullLegs: Skeleton3D not found under bull_v10")
return
# Disable any physical bone simulator — we don't need it
# Disable any physical bone simulator
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:
@@ -108,34 +53,27 @@ func _setup_tail() -> void:
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])
@@ -148,9 +86,6 @@ 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()
@@ -180,190 +115,6 @@ func _wobble_body(delta: float) -> void:
_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:
@@ -373,26 +124,21 @@ func _update_tail(delta: float) -> void:
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()
@@ -402,12 +148,9 @@ func _update_tail(delta: float) -> void:
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()
+6
View File
@@ -87,6 +87,12 @@ func _register_all() -> void:
_reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1)
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
_reg_f("Matador", "mat_ragdoll_impulse", 3.0, 0.5, 20.0)
_reg_f("Matador", "mat_flee_range", 10.0, 2.0, 40.0)
_reg_f("Matador", "mat_flee_speed", 4.0, 0.5, 12.0)
_reg_f("Matador", "mat_dodge_range", 3.5, 1.0, 10.0)
_reg_f("Matador", "mat_dodge_speed", 7.0, 1.0, 20.0)
_reg_f("Matador", "mat_dodge_duration", 0.5, 0.1, 2.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 1.5, 0.0, 5.0, 0.1)
# ── Debug ─────────────────────────────────────────────────────────────────
_reg_b("Debug", "show_collisions", false)
+106 -14
View File
@@ -1,6 +1,6 @@
extends CharacterBody3D
enum State { WANDER, RAGDOLL }
enum State { WANDER, FLEE, DODGE, RAGDOLL }
var _state: State = State.WANDER
var _skeleton: Skeleton3D = null
@@ -8,6 +8,10 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0
var _bull: CharacterBody3D = null
var _dodge_dir: Vector3 = Vector3.ZERO
var _dodge_timer: float = 0.0
var _dodge_cd: float = 0.0
@onready var _mesh: Node3D = $matador_v02
@onready var _hit_area: Area3D = $HitArea
@@ -35,13 +39,42 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if _bull == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_bull = players[0] as CharacterBody3D
if not is_on_floor():
velocity += get_gravity() * delta
_dodge_cd = maxf(0.0, _dodge_cd - delta)
if _state != State.RAGDOLL and _bull != null:
_update_ai_state()
match _state:
State.WANDER: _tick_wander(delta)
State.FLEE: _tick_flee(delta)
State.DODGE: _tick_dodge(delta)
State.RAGDOLL: _tick_ragdoll(delta)
func _update_ai_state() -> void:
var dist := global_position.distance_to(_bull.global_position)
match _state:
State.WANDER:
if dist < DP.f("mat_flee_range"):
_state = State.FLEE
State.FLEE:
if dist < DP.f("mat_dodge_range") and _dodge_cd <= 0.0:
_start_dodge()
elif dist > DP.f("mat_flee_range") * 1.3:
_state = State.WANDER
_pick_wander_target()
State.DODGE:
pass
func _tick_wander(delta: float) -> void:
if _idle_timer > 0.0:
_idle_timer -= delta
@@ -67,9 +100,54 @@ func _tick_wander(delta: float) -> void:
move_and_slide()
func _tick_ragdoll(delta: float) -> void:
velocity.x = move_toward(velocity.x, 0.0, 15.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 15.0 * delta)
func _tick_flee(delta: float) -> void:
var away := (global_position - _bull.global_position)
away.y = 0.0
if away.length() < 0.01:
away = Vector3(randf() - 0.5, 0.0, randf() - 0.5)
away = away.normalized()
var spd := DP.f("mat_flee_speed")
velocity.x = away.x * spd
velocity.z = away.z * spd
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0)
_play_anim(_WALK_ANIM)
move_and_slide()
func _start_dodge() -> void:
_state = State.DODGE
var bull_vel_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var bull_fwd: Vector3
if bull_vel_flat.length() > 1.0:
bull_fwd = bull_vel_flat.normalized()
else:
bull_fwd = (_bull.global_position - global_position)
bull_fwd.y = 0.0
bull_fwd = bull_fwd.normalized()
# Step sideways relative to the bull's travel direction, biased slightly backward
var side := 1.0 if randf() > 0.5 else -1.0
var perp := bull_fwd.rotated(Vector3.UP, PI * 0.5 * side)
_dodge_dir = (perp * 0.7 + (-bull_fwd) * 0.3).normalized()
_dodge_timer = DP.f("mat_dodge_duration")
func _tick_dodge(delta: float) -> void:
_dodge_timer -= delta
if _dodge_timer <= 0.0:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
return
var spd := DP.f("mat_dodge_speed")
velocity.x = _dodge_dir.x * spd
velocity.z = _dodge_dir.z * spd
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 12.0)
_play_anim(_WALK_ANIM)
move_and_slide()
func _tick_ragdoll(_delta: float) -> void:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
@@ -90,18 +168,30 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
hit_dir.y = 0.0
hit_dir = hit_dir.normalized()
# Cap lateral slide so matadors don't cross the arena; fixed upward pop
var lateral: float = minf(bull_speed * 0.2, 4.0)
velocity = Vector3(hit_dir.x * lateral, 2.0, hit_dir.z * lateral)
# Keep the CharacterBody3D stationary: physics bones simulate relative to the
# skeleton root, so sliding the root makes every bone pose diverge.
velocity = Vector3.ZERO
# Disable body capsule so a fast bull doesn't bounce off a ragdolling matador
_body_col.disabled = true
# Remove from collision layer so the bull passes through, but keep the
# shape enabled — move_and_slide() still needs it to detect the floor.
collision_layer = 0
if _anim_player:
_anim_player.stop()
_anim_player.pause() # pause keeps current frame; stop() resets to T-pose
if not _sim:
return
# Teleport each physics body to its current bone world transform, then
# enable per-bone simulation. _sim.active alone does NOT set simulate_physics;
# without it _process_modification() skips every bone and writes nothing.
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var bone_idx := _skeleton.find_bone(pb.bone_name)
if bone_idx >= 0:
pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx)
pb.simulate_physics = true
_sim.active = true
var strength := DP.f("mat_ragdoll_impulse")
for child: Node in _sim.get_children():
@@ -181,10 +271,12 @@ func _setup_physical_bones() -> PhysicalBoneSimulator3D:
if _skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.linear_damp = 1.0
pb.angular_damp = 1.0
pb.bone_name = bname
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.linear_damp = 3.0
pb.angular_damp = 4.0
pb.collision_layer = 1
pb.collision_mask = 1
var cs := CollisionShape3D.new()
var use_capsule: bool = entry.size() >= 3
if use_capsule:
-2
View File
@@ -16,7 +16,6 @@ transform = Transform3D(0.030703627, 0.98235905, -0.18446636, -0.1612905, 0.1870
shadow_enabled = true
[node name="Node3D" type="Node3D" parent="." unique_id=1081100876]
transform = Transform3D(10, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0)
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=2072095624]
@@ -25,7 +24,6 @@ transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
shape = SubResource("BoxShape3D_ulcgi")
[node name="arena_placeholder_v01" parent="Node3D" unique_id=1341659233 instance=ExtResource("2_nxogm")]
transform = Transform3D(0.28002706, 0, 0, 0, 1, 0, 0, 0, 0.43545616, -0.2506242, 0, 0)
[node name="MatadorSpawner" type="Node3D" parent="." unique_id=3001000001]
script = ExtResource("3_spawn")