Files
Bullosseum/matador.gd
T

475 lines
16 KiB
GDScript

extends CharacterBody3D
# WANDER → bull outside flee range, roaming freely
# FLEE → bull within range, matador backs away
# BRACE → charge detected: plant feet, face the bull (cite phase)
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
# RAGDOLL → hit by bull at speed
enum State { WANDER, FLEE, BRACE, SIDESTEP, RAGDOLL }
signal killed
var _state: State = State.WANDER
var _skeleton: Skeleton3D = null
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 _step_dir: Vector3 = Vector3.ZERO
var _brace_timer: float = 0.0
var _step_timer: float = 0.0
var _dodge_cd: float = 0.0
var _will_dodge: bool = true
var _ole_player: AudioStreamPlayer = null
var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null
@onready var _mesh: Node3D = $matador_v02
@onready var _hit_area: Area3D = $HitArea
@onready var _body_col: CollisionShape3D = $CollisionShape3D
const _WALK_ANIM: StringName = &"Armature|walk"
const _IDLE_ANIM: StringName = &"Armature|idle"
func _ready() -> void:
add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
if _skeleton:
_sim = _setup_physical_bones()
if _anim_player:
_ensure_loop(_WALK_ANIM)
if _anim_player.has_animation(_IDLE_ANIM):
_anim_player.play(_IDLE_ANIM)
else:
push_warning("Matador: idle animation not found. Available: %s" %
str(_anim_player.get_animation_list()))
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_setup_blood_burst()
if ResourceLoader.exists("res://sounds/ole.ogg"):
_ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg")
_ole_player.volume_db = 0.0
add_child(_ole_player)
if ResourceLoader.exists("res://sounds/matador_death.ogg"):
_death_player = AudioStreamPlayer.new()
_death_player.stream = load("res://sounds/matador_death.ogg")
_death_player.volume_db = 2.0
add_child(_death_player)
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.BRACE: _tick_brace(delta)
State.SIDESTEP: _tick_sidestep(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 _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()):
_start_brace()
elif dist > DP.f("mat_flee_range") * 1.3:
_state = State.WANDER
_pick_wander_target()
State.BRACE:
pass # brace timer drives transition
State.SIDESTEP:
pass # step timer drives transition
# Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if bull_flat.length() < DP.f("mat_charge_speed"):
return false
var to_me := (global_position - _bull.global_position)
to_me.y = 0.0
if to_me.length() > DP.f("mat_brace_range"):
return false
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
func _tick_wander(delta: float) -> void:
if _idle_timer > 0.0:
_idle_timer -= delta
velocity.x = move_toward(velocity.x, 0.0, 10.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 10.0 * delta)
_play_anim(_IDLE_ANIM)
move_and_slide()
return
var dx: float = _wander_target.x - global_position.x
var dz: float = _wander_target.z - global_position.z
if Vector2(dx, dz).length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_pick_wander_target()
else:
var dir := Vector3(dx, 0.0, dz).normalized()
var spd := DP.f("mat_walk_speed")
velocity.x = dir.x * spd
velocity.z = dir.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 _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()
# Cite phase: plant feet, face the bull — wait for it to commit
func _start_brace() -> void:
_state = State.BRACE
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
# Use bull velocity if it's moving, else use bull→matador direction as proxy
var charge_dir: Vector3
if bull_flat.length() > 1.0:
charge_dir = bull_flat.normalized()
else:
charge_dir = (_bull.global_position - global_position)
charge_dir.y = 0.0
charge_dir = charge_dir.normalized()
# Decide whether to dodge at all and which side — fixed before the bull arrives
_will_dodge = randf() < DP.f("mat_dodge_chance")
var side := 1.0 if randf() > 0.5 else -1.0
_step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side)
_brace_timer = DP.f("mat_brace_duration")
func _tick_brace(delta: float) -> void:
# Decelerate to a stop
velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta)
# Face the bull
var to_bull := (_bull.global_position - global_position)
to_bull.y = 0.0
if to_bull.length() > 0.1:
_mesh.rotation.y = lerp_angle(
_mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 14.0)
_play_anim(_IDLE_ANIM)
move_and_slide()
_brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position)
# Execute the step when the bull is almost on top or the brace window expires
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge:
_state = State.SIDESTEP
_step_timer = DP.f("mat_step_duration")
if _ole_player:
_ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play()
else:
# Stand and eat it — no juke
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Pase phase: sharp lateral step as the bull commits to its line
func _tick_sidestep(delta: float) -> void:
_step_timer -= delta
if _step_timer <= 0.0:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
return
var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd
velocity.z = _step_dir.z * spd
# Snap facing quickly to sell the movement
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.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()
func _on_body_entered(body: Node3D) -> void:
if _state == State.RAGDOLL:
return
if not body.is_in_group(&"player"):
return
var player := body as CharacterBody3D
if player.velocity.length() < DP.f("mat_hit_threshold"):
return
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
(global_position - player.global_position).normalized()
_enter_ragdoll(hit_dir, player.velocity.length())
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
_state = State.RAGDOLL
killed.emit()
var _t := get_tree().create_timer(4.0)
_t.timeout.connect(func() -> void:
if is_instance_valid(self):
queue_free()
)
hit_dir.y = 0.0
hit_dir = hit_dir.normalized()
# Arc upward so the body lifts before falling — feels like a real goring throw
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_spawn_blood_burst(hit_dir)
if _death_player:
_death_player.pitch_scale = randf_range(0.9, 1.1)
_death_player.play()
var cam := _bull.get_node_or_null("Camera3D")
if cam:
cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0))
# Keep the CharacterBody3D stationary: physics bones simulate relative to the
# skeleton root, so sliding the root makes every bone pose diverge.
velocity = Vector3.ZERO
# 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.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)
_sim.active = true
_sim.physical_bones_start_simulation()
var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3)
var right := throw_dir.cross(Vector3.UP).normalized()
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var impulse := Vector3.ZERO
match pb.bone_name:
"COG", "matador":
# Core of mass takes the full hit
impulse = throw_dir * strength
"chest":
impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9
"head":
impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7
"leg_L":
impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"leg_R":
impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"arm_L":
impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"arm_R":
impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
_:
# Distal bones (forearms, shins, hands, feet) get no direct impulse;
# they trail naturally from the joints above them
continue
# Small random noise per bone so no two look identical
var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15))
pb.apply_central_impulse(impulse + noise * strength * 0.2)
# ── Blood burst ───────────────────────────────────────────────────────────────
func _setup_blood_burst() -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.5))
scale_curve.add_point(Vector2(1.0, 0.0))
_blood_burst = CPUParticles3D.new()
_blood_burst.mesh = sphere
_blood_burst.color_ramp = ramp
_blood_burst.scale_amount_curve = scale_curve
_blood_burst.one_shot = true
_blood_burst.explosiveness = 0.9
_blood_burst.amount = 25
_blood_burst.lifetime = 1.1
_blood_burst.randomness = 0.4
_blood_burst.local_coords = false
_blood_burst.spread = 80.0
_blood_burst.gravity = Vector3(0.0, -7.0, 0.0)
_blood_burst.initial_velocity_min = 4.0
_blood_burst.initial_velocity_max = 11.0
_blood_burst.scale_amount_min = 0.5
_blood_burst.scale_amount_max = 1.6
_blood_burst.emitting = false
add_child(_blood_burst)
func _spawn_blood_burst(hit_dir: Vector3) -> void:
_blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0)
_blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized()
_blood_burst.restart()
# ── Animation helpers ─────────────────────────────────────────────────────────
func _ensure_loop(anim_name: StringName) -> void:
if _anim_player.has_animation(anim_name):
var anim := _anim_player.get_animation(anim_name)
anim.loop_mode = Animation.LOOP_LINEAR
func _play_anim(anim_name: StringName) -> void:
if not _anim_player:
return
if not _anim_player.has_animation(anim_name):
return
if _anim_player.current_animation != anim_name:
_anim_player.play(anim_name)
func _pick_wander_target() -> void:
var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU
var dist := randf_range(2.0, radius)
_wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)
# ── Ragdoll setup ─────────────────────────────────────────────────────────────
func _setup_physical_bones() -> PhysicalBoneSimulator3D:
for child: Node in _skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
_skeleton.add_child(sim)
const BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
]
for entry: Array in BONES:
var bname: String = entry[0]
if _skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.4
pb.linear_damp = 0.1
pb.angular_damp = 0.2
pb.collision_layer = 1
pb.collision_mask = 1
var cs := CollisionShape3D.new()
var use_capsule: bool = entry.size() >= 3
if use_capsule:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child: Node in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node as AnimationPlayer
for child: Node in node.get_children():
var r := _find_anim_player(child)
if r:
return r
return null