1284 lines
47 KiB
GDScript
1284 lines
47 KiB
GDScript
extends CharacterBody3D
|
|
|
|
# Hoof positions in bull local space — dust emitter offsets
|
|
const HOOF_OFFSETS: Array = [
|
|
Vector3(-0.30, -0.25, -0.50),
|
|
Vector3( 0.30, -0.25, -0.50),
|
|
Vector3(-0.30, -0.25, 0.60),
|
|
Vector3( 0.30, -0.25, 0.60),
|
|
]
|
|
|
|
# Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the
|
|
# looping base pose; the others are one-shot ability clips that return to it.
|
|
const _ANIM_IDLE: StringName = &"Armature|IDLE"
|
|
const _ANIM_KICK: StringName = &"Armature|KICK"
|
|
const _ANIM_SLAM: StringName = &"Armature|SLAM"
|
|
const _ANIM_DASH: StringName = &"Armature|DASH"
|
|
const _ANIM_WALK: StringName = &"Armature|WALK"
|
|
const _ANIM_ROLL: StringName = &"Armature|ROLL"
|
|
const _ANIM_DEATH: StringName = &"Armature|DEATH"
|
|
|
|
@onready var camera_pivot: Node3D = $Camera3D
|
|
@onready var cube_guy: Node3D = $bull
|
|
|
|
var _hoof_emitters: Array[CPUParticles3D] = []
|
|
var _legs: Node
|
|
|
|
var _was_on_floor: bool = false
|
|
var _pre_slide_vel_y: float = 0.0
|
|
|
|
var _slam_ring_mesh: ArrayMesh = null
|
|
|
|
# Ability system — kick=0, dash=1, slam=2, roll=3
|
|
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
|
|
var _ability_active: bool = false
|
|
var _ability_timer: float = 0.0
|
|
var _active_ability: int = -1
|
|
var _dash_dir: Vector3 = Vector3.ZERO
|
|
var _dash_speed_cur: float = 0.0
|
|
var _roll_dir: Vector3 = Vector3.ZERO
|
|
var _roll_ramp: float = 0.0
|
|
var _roll_spin: float = 0.0
|
|
var _roll_target: Node3D = null
|
|
var _roll_chains_left: int = 0
|
|
var _roll_hit: Array[Node] = []
|
|
var _bull_anim: AnimationPlayer = null
|
|
# Locomotion animation state: true while the looping DASH clip is driving WASD
|
|
# movement, false while idling. Flipping it cross-fades between the two clips.
|
|
var _locomotion_moving: bool = false
|
|
|
|
var _slam_pending: bool = false
|
|
var _charge_trail_emitter: CPUParticles3D = null
|
|
var _roll_spark_emitter: CPUParticles3D = null
|
|
var _cube_scale: Vector3 = Vector3.ONE
|
|
|
|
# One clean hit ends the run — see take_sword_hit(). The HUD listens for `died`
|
|
# to raise the lose screen (the `cause` tags how it happened, for the death notice);
|
|
# _dead latches so a flurry of hits fires it only once.
|
|
signal died(cause: String)
|
|
var _dead: bool = false
|
|
|
|
# HP system — the bull soaks several clean hits instead of dying to the first.
|
|
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
|
|
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
|
|
signal health_changed(current: int, max_hp: int)
|
|
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
|
|
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
|
|
signal hit_taken(cause: String)
|
|
var _max_hp: int = 10
|
|
var _hp: int = 10
|
|
# Bonus pips granted by lobby rewards (e.g. smashing every barrel). Tacked on top of the
|
|
# base bar; the HUD paints these top pips yellow instead of red.
|
|
var _bonus_hp: int = 0
|
|
var _hit_iframes: float = 0.0
|
|
|
|
var _huff_player: AudioStreamPlayer = null
|
|
var _crowd_player: AudioStreamPlayer = null
|
|
var _huff_timer: float = 0.0
|
|
|
|
enum DustState { NONE, WALK, CHARGE }
|
|
var _dust_state: DustState = DustState.NONE
|
|
var _walk_dust_ramp: Gradient = null
|
|
var _charge_dust_ramp: Gradient = null
|
|
|
|
|
|
const RigidSkin = preload("res://rigid_skin.gd")
|
|
|
|
var _hit_flash_tween: Tween
|
|
|
|
|
|
func _ready() -> void:
|
|
add_to_group(&"player")
|
|
# Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform
|
|
# feedback) — skinned meshes render invisible on the web build. Rebuild the bull as
|
|
# non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning.
|
|
if OS.has_feature("web"):
|
|
RigidSkin.convert_tree(self)
|
|
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
|
_hp = _max_hp
|
|
# Carry any run-long bonus pips (barrel reward) into this level so they survive the
|
|
# scene reload between fights; the HUD paints these top pips yellow.
|
|
_bonus_hp = Run.bonus_hp
|
|
_max_hp += _bonus_hp
|
|
_hp += _bonus_hp
|
|
_cube_scale = cube_guy.scale
|
|
_setup_hoof_dust()
|
|
_setup_legs()
|
|
_setup_charge_trail()
|
|
_setup_roll_sparks()
|
|
_setup_audio()
|
|
_bull_anim = _find_anim_player(cube_guy)
|
|
_setup_bull_idle()
|
|
DP.any_changed.connect(_on_dp_changed)
|
|
|
|
|
|
func _setup_legs() -> void:
|
|
_legs = Node.new()
|
|
_legs.set_script(load("res://bull_legs.gd"))
|
|
add_child(_legs)
|
|
_legs.call(&"setup", self, cube_guy)
|
|
|
|
|
|
func _setup_hoof_dust() -> void:
|
|
var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
|
|
|
|
_walk_dust_ramp = Gradient.new()
|
|
_walk_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
|
|
_walk_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
|
|
|
|
# White-beige dust while running
|
|
_charge_dust_ramp = Gradient.new()
|
|
_charge_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
|
|
_charge_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 0.2))
|
|
scale_curve.add_point(Vector2(0.4, 1.0))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
for offset: Vector3 in HOOF_OFFSETS:
|
|
var p := CPUParticles3D.new()
|
|
p.position = offset
|
|
p.emitting = false
|
|
p.amount = 12
|
|
p.lifetime = DP.f("dust_lifetime")
|
|
p.explosiveness = DP.f("dust_explosiveness")
|
|
p.randomness = 0.6
|
|
p.local_coords = false
|
|
p.direction = Vector3(0.0, 1.0, 0.0)
|
|
p.spread = DP.f("dust_spread")
|
|
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
|
|
p.initial_velocity_min = DP.f("walk_vel_min")
|
|
p.initial_velocity_max = DP.f("walk_vel_max")
|
|
p.scale_amount_min = DP.f("walk_scale_min")
|
|
p.scale_amount_max = DP.f("walk_scale_max")
|
|
p.mesh = sphere
|
|
p.color_ramp = _walk_dust_ramp
|
|
p.scale_amount_curve = scale_curve
|
|
cube_guy.add_child(p)
|
|
_hoof_emitters.append(p)
|
|
|
|
|
|
func _setup_charge_trail() -> void:
|
|
# Blue energy trail — streams backward during a fast run / roll
|
|
var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material())
|
|
|
|
var trail_ramp := Gradient.new()
|
|
trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0))
|
|
trail_ramp.set_color(1, Color(0.30, 0.65, 1.0, 0.0))
|
|
|
|
var trail_scale := Curve.new()
|
|
trail_scale.add_point(Vector2(0.0, 1.0))
|
|
trail_scale.add_point(Vector2(0.6, 0.8))
|
|
trail_scale.add_point(Vector2(1.0, 0.0))
|
|
|
|
_charge_trail_emitter = CPUParticles3D.new()
|
|
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
|
|
_charge_trail_emitter.emitting = false
|
|
_charge_trail_emitter.one_shot = false
|
|
_charge_trail_emitter.amount = 18
|
|
_charge_trail_emitter.lifetime = 0.30
|
|
_charge_trail_emitter.randomness = 0.4
|
|
_charge_trail_emitter.local_coords = false
|
|
_charge_trail_emitter.direction = Vector3(0.0, 0.2, 1.0).normalized()
|
|
_charge_trail_emitter.spread = 25.0
|
|
_charge_trail_emitter.gravity = Vector3(0.0, 2.0, 0.0)
|
|
_charge_trail_emitter.initial_velocity_min = 4.0
|
|
_charge_trail_emitter.initial_velocity_max = 9.0
|
|
_charge_trail_emitter.scale_amount_min = 0.4
|
|
_charge_trail_emitter.scale_amount_max = 1.2
|
|
_charge_trail_emitter.mesh = trail_sphere
|
|
_charge_trail_emitter.color_ramp = trail_ramp
|
|
_charge_trail_emitter.scale_amount_curve = trail_scale
|
|
cube_guy.add_child(_charge_trail_emitter)
|
|
|
|
|
|
func _setup_roll_sparks() -> void:
|
|
# Rammus Powerball ground spray — dirt/sparks flung outward from the spinning
|
|
# ball. World-space (local_coords off) so they peel off into a trail behind it.
|
|
var spark := _particle_sphere(0.05, 4, 2, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(1.00, 0.78, 0.32, 1.0))
|
|
ramp.set_color(1, Color(0.70, 0.42, 0.14, 0.0))
|
|
|
|
var sc := Curve.new()
|
|
sc.add_point(Vector2(0.0, 0.30))
|
|
sc.add_point(Vector2(0.3, 1.00))
|
|
sc.add_point(Vector2(1.0, 0.00))
|
|
|
|
_roll_spark_emitter = CPUParticles3D.new()
|
|
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
|
|
_roll_spark_emitter.emitting = false
|
|
_roll_spark_emitter.one_shot = false
|
|
_roll_spark_emitter.amount = 46
|
|
_roll_spark_emitter.lifetime = 0.45
|
|
_roll_spark_emitter.randomness = 0.7
|
|
_roll_spark_emitter.local_coords = false
|
|
_roll_spark_emitter.direction = Vector3(0.0, 1.0, 0.0)
|
|
_roll_spark_emitter.spread = 180.0
|
|
_roll_spark_emitter.gravity = Vector3(0.0, -6.0, 0.0)
|
|
_roll_spark_emitter.initial_velocity_min = 3.0
|
|
_roll_spark_emitter.initial_velocity_max = 8.0
|
|
_roll_spark_emitter.scale_amount_min = 0.5
|
|
_roll_spark_emitter.scale_amount_max = 1.3
|
|
_roll_spark_emitter.mesh = spark
|
|
_roll_spark_emitter.color_ramp = ramp
|
|
_roll_spark_emitter.scale_amount_curve = sc
|
|
cube_guy.add_child(_roll_spark_emitter)
|
|
|
|
|
|
func _setup_audio() -> void:
|
|
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
|
|
_huff_player = AudioStreamPlayer.new()
|
|
_huff_player.stream = load("res://sounds/bull_huff.ogg")
|
|
_huff_player.volume_db = 0.0
|
|
_huff_player.bus = &"Effects"
|
|
add_child(_huff_player)
|
|
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
|
|
_crowd_player = AudioStreamPlayer.new()
|
|
var s := load("res://sounds/crowd_ambient.ogg") as AudioStreamOggVorbis
|
|
if s:
|
|
s.loop = true
|
|
_crowd_player.stream = s
|
|
_crowd_player.volume_db = -14.0
|
|
_crowd_player.autoplay = true
|
|
_crowd_player.bus = &"Ambient"
|
|
add_child(_crowd_player)
|
|
|
|
|
|
func _set_dust_state(new_state: DustState) -> void:
|
|
if new_state == _dust_state:
|
|
return
|
|
_dust_state = new_state
|
|
for p: CPUParticles3D in _hoof_emitters:
|
|
p.lifetime = DP.f("dust_lifetime")
|
|
p.explosiveness = DP.f("dust_explosiveness")
|
|
p.spread = DP.f("dust_spread")
|
|
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
|
|
match new_state:
|
|
DustState.NONE:
|
|
p.emitting = false
|
|
DustState.WALK:
|
|
p.color_ramp = _walk_dust_ramp
|
|
p.scale_amount_min = DP.f("walk_scale_min")
|
|
p.scale_amount_max = DP.f("walk_scale_max")
|
|
p.initial_velocity_min = DP.f("walk_vel_min")
|
|
p.initial_velocity_max = DP.f("walk_vel_max")
|
|
p.emitting = true
|
|
DustState.CHARGE:
|
|
p.color_ramp = _charge_dust_ramp
|
|
p.scale_amount_min = DP.f("charge_scale_min")
|
|
p.scale_amount_max = DP.f("charge_scale_max")
|
|
p.initial_velocity_min = DP.f("charge_vel_min")
|
|
p.initial_velocity_max = DP.f("charge_vel_max")
|
|
p.emitting = true
|
|
# Blue energy trail belongs to the roll/dash ability only, not to fast WASD
|
|
# running — driven explicitly in the roll loop / _end_roll, not by dust state.
|
|
|
|
|
|
func _on_dp_changed(_key: String, _val: Variant) -> void:
|
|
if _hoof_emitters.is_empty() or _dust_state == DustState.NONE:
|
|
return
|
|
var prev := _dust_state
|
|
_dust_state = DustState.NONE
|
|
_set_dust_state(prev)
|
|
|
|
|
|
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
|
|
|
|
|
|
# IDLE ships one-shot; make it loop and start it so the bull breathes at rest
|
|
# instead of freezing on a bind pose. It's the base every ability blends back to.
|
|
func _setup_bull_idle() -> void:
|
|
if _bull_anim == null:
|
|
return
|
|
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
|
|
# locomotion cycle); the remaining ability clips are one-shot.
|
|
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
|
|
if _bull_anim.has_animation(clip):
|
|
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
|
_play_idle()
|
|
|
|
|
|
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
|
|
# finishing ability clip eases back to idle instead of snapping.
|
|
func _play_idle() -> void:
|
|
_locomotion_moving = false
|
|
if _bull_anim == null:
|
|
return
|
|
_bull_anim.speed_scale = 1.0
|
|
if _bull_anim.has_animation(_ANIM_IDLE):
|
|
_bull_anim.play(_ANIM_IDLE, 0.05)
|
|
|
|
|
|
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
|
|
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
|
|
# on each transition so idle↔run (and steering into it after an ability) blend rather
|
|
# than snap. Abilities own the animation while active, so this yields to them.
|
|
func _update_locomotion_anim(moving: bool) -> void:
|
|
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
|
|
return
|
|
_locomotion_moving = moving
|
|
if moving:
|
|
if _bull_anim.has_animation(_ANIM_WALK):
|
|
_bull_anim.speed_scale = 2.0
|
|
_bull_anim.play(_ANIM_WALK, 1)
|
|
var anim = _bull_anim.get_animation(_ANIM_WALK)
|
|
anim.loop_mode = Animation.LOOP_LINEAR
|
|
else:
|
|
_play_idle()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if _dead:
|
|
return
|
|
# Abilities can be chained: a new one interrupts whatever is currently
|
|
# active, gated only by each ability's own cooldown.
|
|
if event.is_action_pressed(&"ability_kick"):
|
|
try_activate_ability(0)
|
|
elif event.is_action_pressed(&"ability_dash"):
|
|
try_activate_ability(1)
|
|
elif event.is_action_pressed(&"ability_slam"):
|
|
try_activate_ability(2)
|
|
elif event.is_action_pressed(&"ability_roll"):
|
|
try_activate_ability(3)
|
|
|
|
|
|
# Public ability entry point shared by keyboard/gamepad input and the on-screen
|
|
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
|
|
# No-ops if the bull is dead or the slot is still cooling down.
|
|
func try_activate_ability(slot: int) -> void:
|
|
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
|
|
return
|
|
match slot:
|
|
0: _activate_kick()
|
|
1: _activate_dash()
|
|
2: _activate_slam()
|
|
3: _activate_roll()
|
|
|
|
|
|
func ability_cooldown_fraction(slot: int) -> float:
|
|
var maxcd := 0.0
|
|
match slot:
|
|
0: maxcd = DP.f("kick_cooldown")
|
|
1: maxcd = DP.f("dash_cooldown")
|
|
2: maxcd = DP.f("slam_cooldown")
|
|
3: maxcd = DP.f("roll_cooldown")
|
|
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
|
|
|
|
|
|
func ability_cooldown_remaining(slot: int) -> float:
|
|
return ability_cd[slot]
|
|
|
|
|
|
func _activate_kick() -> void:
|
|
ability_cd[0] = DP.f("kick_cooldown")
|
|
_ability_active = true
|
|
_active_ability = 0
|
|
_ability_timer = 0.3
|
|
if _bull_anim:
|
|
_bull_anim.speed_scale = 2.0
|
|
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
|
|
_bull_anim.play(_ANIM_KICK)
|
|
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
|
|
var raw := cube_guy.global_transform.basis.z
|
|
var back := -Vector3(raw.x, 0.0, raw.z).normalized()
|
|
_spawn_kick_fx(back)
|
|
get_tree().create_timer(0.2).timeout.connect(
|
|
func() -> void: _hit_matadors_cone(DP.f("kick_range"), deg_to_rad(85.0), DP.f("kick_strength"), back)
|
|
)
|
|
|
|
|
|
func _activate_slam() -> void:
|
|
ability_cd[2] = DP.f("slam_cooldown")
|
|
_ability_active = true
|
|
_active_ability = 2
|
|
_legs.set(&"tail_ragdoll", true)
|
|
_slam_pending = true
|
|
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
|
|
var jump_vel := DP.f("slam_jump_velocity")
|
|
velocity.y = jump_vel
|
|
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
|
|
var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01)
|
|
var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult))
|
|
_ability_timer = air_time + 0.3
|
|
if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM):
|
|
var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
|
|
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
|
|
_bull_anim.play(_ANIM_SLAM)
|
|
|
|
|
|
func _activate_dash() -> void:
|
|
ability_cd[1] = DP.f("dash_cooldown")
|
|
_ability_active = true
|
|
_active_ability = 1
|
|
_ability_timer = 0.45
|
|
if _bull_anim:
|
|
_bull_anim.speed_scale = 1.0
|
|
if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
|
|
_bull_anim.play(_ANIM_DASH)
|
|
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
|
|
var raw := cube_guy.global_transform.basis.z
|
|
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
|
|
_dash_speed_cur = DP.f("dash_speed")
|
|
velocity.x = _dash_dir.x * _dash_speed_cur
|
|
velocity.z = _dash_dir.z * _dash_speed_cur
|
|
_spawn_dash_burst()
|
|
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
|
|
if _huff_player:
|
|
_huff_player.pitch_scale = randf_range(0.9, 1.1)
|
|
_huff_player.play()
|
|
|
|
|
|
func _activate_roll() -> void:
|
|
ability_cd[3] = DP.f("roll_cooldown")
|
|
_ability_active = true
|
|
_active_ability = 3
|
|
_ability_timer = DP.f("roll_duration")
|
|
_roll_ramp = 0.0
|
|
_roll_spin = 0.0
|
|
_roll_chains_left = int(DP.f("roll_chain_count"))
|
|
_roll_hit.clear()
|
|
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
|
|
# is empty, launch along the current heading (or facing when standing still).
|
|
_roll_target = _roll_pick_target()
|
|
var flat := Vector3(velocity.x, 0.0, velocity.z)
|
|
if _roll_target != null:
|
|
var to_t := _roll_target.global_position - global_position
|
|
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
|
|
elif flat.length() > 1.0:
|
|
_roll_dir = flat.normalized()
|
|
else:
|
|
var f := cube_guy.global_transform.basis.z
|
|
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
|
|
var launch := DP.f("roll_base_speed")
|
|
velocity.x = _roll_dir.x * launch
|
|
velocity.z = _roll_dir.z * launch
|
|
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
|
|
_bull_anim.speed_scale = 1.0
|
|
_bull_anim.play(_ANIM_ROLL)
|
|
_legs.set(&"tail_ragdoll", true) # tail streams out behind
|
|
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
|
|
_legs.set(&"external_rotation", true) # roll owns the model's full orientation (tumble)
|
|
if _roll_spark_emitter:
|
|
_roll_spark_emitter.emitting = true
|
|
_spawn_dash_burst()
|
|
camera_pivot.call(&"trigger_hit", 0.35)
|
|
if _huff_player:
|
|
_huff_player.pitch_scale = randf_range(0.8, 1.0)
|
|
_huff_player.play()
|
|
|
|
|
|
# One rolling frame: home on the current target matador, accelerate the longer the
|
|
# roll lasts, move (walls just slide), then pop the ball on contact and chain to the
|
|
# next closest. Fully replaces the normal locomotion path while active. `steer` is
|
|
# ignored — the roll auto-targets — but kept so the caller stays uniform.
|
|
func _tick_roll(delta: float, _steer: Vector3) -> void:
|
|
# Reacquire if the target ragdolled or was freed before we reached it.
|
|
if not _roll_target_valid():
|
|
_roll_target = _roll_pick_target()
|
|
if _roll_target != null:
|
|
var to_t := _roll_target.global_position - global_position
|
|
var flat := Vector3(to_t.x, 0.0, to_t.z)
|
|
if flat.length() > 0.1:
|
|
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
|
|
_roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized()
|
|
|
|
# Speed builds over time: base → max across roll_rampup_time, so a long roll
|
|
# winds up into a runaway boulder while a quick tap barely gets going.
|
|
_roll_ramp += delta
|
|
var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0)
|
|
var top := DP.f("roll_max_speed")
|
|
var speed := lerpf(DP.f("roll_base_speed"), top, ramp)
|
|
velocity.x = _roll_dir.x * speed
|
|
velocity.z = _roll_dir.z * speed
|
|
|
|
# Tumble the whole model like a Powerball: face the travel direction (yaw) then
|
|
# spin forward about the horizontal axis perpendicular to it, angular speed = v/r
|
|
# so it "rolls" without slipping. Legs yield rotation control while active.
|
|
var radius := maxf(DP.f("roll_spin_radius"), 0.1)
|
|
_roll_spin = wrapf(_roll_spin + (speed / radius) * delta, 0.0, TAU)
|
|
var facing := Basis(Vector3.UP, atan2(_roll_dir.x, _roll_dir.z))
|
|
var tumble_axis := Vector3.UP.cross(_roll_dir).normalized()
|
|
cube_guy.basis = (Basis(tumble_axis, _roll_spin) * facing).scaled(_cube_scale)
|
|
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
|
|
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
|
|
|
|
_set_dust_state(DustState.CHARGE)
|
|
if _charge_trail_emitter:
|
|
_charge_trail_emitter.emitting = true
|
|
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
|
|
|
|
move_and_slide()
|
|
_was_on_floor = is_on_floor()
|
|
_smash_barrels_in_radius(DP.f("roll_hit_radius"))
|
|
_roll_try_pop(speed)
|
|
|
|
|
|
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
|
|
# outward, then either CHAIN to the next closest (if chains remain and a target is
|
|
# left) or end the roll. Returns true if it popped so the caller can stop touching
|
|
# roll state this frame.
|
|
func _roll_try_pop(speed: float) -> bool:
|
|
var radius := DP.f("roll_hit_radius")
|
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
if _roll_hit.has(mat) or not _mat_active(mat):
|
|
continue
|
|
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
to_mat.y = 0.0
|
|
if to_mat.length() > radius:
|
|
continue
|
|
# Record everyone this pop reaches so the chain never re-picks a downed body.
|
|
for m: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
var d: Vector3 = (m as Node3D).global_position - global_position
|
|
d.y = 0.0
|
|
if d.length() <= radius and not _roll_hit.has(m):
|
|
_roll_hit.append(m)
|
|
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
|
|
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
|
|
if _huff_player:
|
|
_huff_player.pitch_scale = randf_range(1.05, 1.35)
|
|
_huff_player.play()
|
|
if _roll_chains_left > 0:
|
|
var next := _roll_pick_target()
|
|
if next != null:
|
|
_roll_chains_left -= 1
|
|
_roll_target = next
|
|
return true # keep rolling — the ball carries on to the next target
|
|
_end_roll()
|
|
return true
|
|
return false
|
|
|
|
|
|
# Closest thing the roll should home on — an active matador (arena) or an intact barrel
|
|
# (lobby) we haven't already smashed this roll. Null if nothing is left to hit.
|
|
func _roll_pick_target() -> Node3D:
|
|
var best: Node3D = null
|
|
var best_d := INF
|
|
for cand: Node3D in _roll_targets():
|
|
var d: float = cand.global_position.distance_squared_to(global_position)
|
|
if d < best_d:
|
|
best_d = d
|
|
best = cand
|
|
return best
|
|
|
|
|
|
# Live roll targets: active matadors plus standing barrels, minus anything already popped.
|
|
func _roll_targets() -> Array[Node3D]:
|
|
var out: Array[Node3D] = []
|
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
if not _roll_hit.has(mat) and _mat_active(mat):
|
|
out.append(mat as Node3D)
|
|
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
|
|
if _barrel_alive(barrel):
|
|
out.append(barrel as Node3D)
|
|
return out
|
|
|
|
|
|
func _roll_target_valid() -> bool:
|
|
if not is_instance_valid(_roll_target):
|
|
return false
|
|
if _roll_target.is_in_group(&"barrel"):
|
|
return _barrel_alive(_roll_target)
|
|
return _mat_active(_roll_target)
|
|
|
|
|
|
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
|
|
# is_active() == false, so a popped target is skipped on the next chain search.
|
|
func _mat_active(mat: Node) -> bool:
|
|
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
|
|
|
|
|
|
# A lobby barrel still standing — valid and not already queued for removal by an earlier hit.
|
|
func _barrel_alive(barrel: Node) -> bool:
|
|
return is_instance_valid(barrel) and not barrel.is_queued_for_deletion()
|
|
|
|
|
|
# Burst every barrel whose centre sits within range_m on the ground plane. Shared by the
|
|
# boulder roll (which ploughs straight through them) and the slam shockwave. Each barrel
|
|
# self-removes and leaves the &"barrel" group, so the HUD's clear-the-lobby tally counts it
|
|
# exactly once.
|
|
func _smash_barrels_in_radius(range_m: float) -> void:
|
|
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
|
|
if not _barrel_alive(barrel):
|
|
continue
|
|
var to_b: Vector3 = (barrel as Node3D).global_position - global_position
|
|
to_b.y = 0.0
|
|
if to_b.length() <= range_m:
|
|
barrel.call(&"destroy_barrel")
|
|
|
|
|
|
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
|
|
# (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring.
|
|
func is_rolling() -> bool:
|
|
return _active_ability == 3 and _ability_active
|
|
|
|
|
|
# True while the dash ability is live. A dash is a committed lunge — it gores/rams on contact
|
|
# regardless of angle (enemies bypass their horns-first cone while it's set).
|
|
func is_dashing() -> bool:
|
|
return _active_ability == 1 and _ability_active
|
|
|
|
|
|
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to the normal
|
|
# locomotion path next frame.
|
|
func _end_roll() -> void:
|
|
_ability_active = false
|
|
_active_ability = -1
|
|
_ability_timer = 0.0
|
|
_roll_ramp = 0.0
|
|
_roll_target = null
|
|
_roll_chains_left = 0
|
|
_roll_hit.clear()
|
|
_legs.set(&"charge_pitch_target", 0.0)
|
|
_legs.set(&"tail_ragdoll", false)
|
|
_legs.set(&"external_rotation", false)
|
|
if _roll_spark_emitter:
|
|
_roll_spark_emitter.emitting = false
|
|
if _charge_trail_emitter:
|
|
_charge_trail_emitter.emitting = false
|
|
velocity.x *= 0.15
|
|
velocity.z *= 0.15
|
|
_play_idle()
|
|
|
|
|
|
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
|
|
dir: Vector3 = Vector3.ZERO) -> void:
|
|
var raw := cube_guy.global_transform.basis.z
|
|
var forward := dir if dir.length_squared() > 0.01 else Vector3(raw.x, 0.0, raw.z).normalized()
|
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
to_mat.y = 0.0
|
|
var dist := to_mat.length()
|
|
if dist > range_m:
|
|
continue
|
|
if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad):
|
|
continue
|
|
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
|
|
|
|
|
|
func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0) -> void:
|
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
to_mat.y = 0.0
|
|
var dist := to_mat.length()
|
|
if dist > range_m:
|
|
continue
|
|
var away := to_mat / dist if dist > 0.01 \
|
|
else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized()
|
|
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
|
|
|
|
|
|
# ── Particle helpers ──────────────────────────────────────────────────────────
|
|
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
|
|
# these three helpers remove the boilerplate each spawn function used to repeat.
|
|
|
|
func _unshaded_material() -> StandardMaterial3D:
|
|
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
|
|
return mat
|
|
|
|
|
|
# Low-poly sphere sized for a particle mesh (height is always the diameter).
|
|
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
|
|
var sphere := SphereMesh.new()
|
|
sphere.radius = radius
|
|
sphere.height = radius * 2.0
|
|
sphere.radial_segments = segments
|
|
sphere.rings = rings
|
|
sphere.material = mat
|
|
return sphere
|
|
|
|
|
|
# Queue-free a transient FX node once its particles have finished.
|
|
func _free_after(node: Node, delay: float) -> void:
|
|
get_tree().create_timer(delay).timeout.connect(func() -> void:
|
|
if is_instance_valid(node):
|
|
node.queue_free()
|
|
)
|
|
|
|
|
|
func _spawn_slam_fx() -> void:
|
|
if not _slam_ring_mesh:
|
|
_slam_ring_mesh = _make_slam_ring_mesh()
|
|
var max_r := DP.f("slam_range")
|
|
var origin := global_position + Vector3(0.0, -0.5, 0.0)
|
|
_launch_slam_ring(origin, max_r, 0.00, 0.55, Color(1.00, 0.70, 0.10, 0.85))
|
|
_launch_slam_ring(origin, max_r, 0.10, 0.52, Color(1.00, 0.50, 0.10, 0.65))
|
|
_launch_slam_ring(origin, max_r, 0.20, 0.48, Color(0.90, 0.30, 0.10, 0.50))
|
|
_spawn_slam_dust(origin)
|
|
camera_pivot.call(&"trigger_hit", 0.60)
|
|
|
|
|
|
func _launch_slam_ring(origin: Vector3, max_r: float,
|
|
delay: float, duration: float, col: Color) -> void:
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
mat.albedo_color = Color(col.r, col.g, col.b, 0.0)
|
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = _slam_ring_mesh
|
|
mi.material_override = mat
|
|
mi.scale = Vector3(0.02, 1.0, 0.02)
|
|
get_parent().add_child(mi)
|
|
mi.global_position = origin
|
|
|
|
var tween := create_tween()
|
|
tween.set_parallel(true)
|
|
tween.tween_property(mi, "scale",
|
|
Vector3(max_r, 1.0, max_r), duration)\
|
|
.set_delay(delay)\
|
|
.set_trans(Tween.TRANS_EXPO)\
|
|
.set_ease(Tween.EASE_OUT)
|
|
var fade := func(a: float) -> void: mat.albedo_color.a = a
|
|
tween.tween_method(fade, col.a, 0.0, duration)\
|
|
.set_delay(delay)\
|
|
.set_trans(Tween.TRANS_SINE)\
|
|
.set_ease(Tween.EASE_IN)
|
|
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
|
|
|
|
|
|
func _spawn_slam_dust(origin: Vector3) -> void:
|
|
var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0))
|
|
ramp.set_color(1, Color(0.88, 0.74, 0.46, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 0.25))
|
|
scale_curve.add_point(Vector2(0.35, 1.6))
|
|
scale_curve.add_point(Vector2(1.00, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.85
|
|
p.amount = 48
|
|
p.lifetime = 1.1
|
|
p.randomness = 0.5
|
|
p.local_coords = false
|
|
p.direction = Vector3.UP
|
|
p.spread = 85.0
|
|
p.gravity = Vector3(0.0, -7.0, 0.0)
|
|
p.initial_velocity_min = 3.5
|
|
p.initial_velocity_max = 10.0
|
|
p.scale_amount_min = 0.5
|
|
p.scale_amount_max = 2.0
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = origin
|
|
p.restart()
|
|
_free_after(p, 2.5)
|
|
|
|
|
|
func _spawn_kick_fx(back_dir: Vector3) -> void:
|
|
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
|
|
_spawn_kick_dust(origin, back_dir)
|
|
_spawn_kick_stones(origin, back_dir)
|
|
_spawn_kick_sparks(origin, back_dir)
|
|
_spawn_kick_flash(origin)
|
|
camera_pivot.call(&"trigger_hit", 0.35)
|
|
|
|
|
|
func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
|
|
var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95))
|
|
ramp.set_color(1, Color(1.0, 0.45, 0.05, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 0.3))
|
|
scale_curve.add_point(Vector2(0.3, 1.5))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.80
|
|
p.amount = 40
|
|
p.lifetime = 1.2
|
|
p.randomness = 0.5
|
|
p.local_coords = false
|
|
p.direction = (back_dir + Vector3(0.0, 0.7, 0.0)).normalized()
|
|
p.spread = 65.0
|
|
p.gravity = Vector3(0.0, -6.0, 0.0)
|
|
p.initial_velocity_min = 3.0
|
|
p.initial_velocity_max = 9.0
|
|
p.scale_amount_min = 0.5
|
|
p.scale_amount_max = 2.2
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = origin
|
|
p.restart()
|
|
_free_after(p, 2.5)
|
|
|
|
|
|
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
|
|
var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0))
|
|
ramp.set_color(1, Color(0.62, 0.58, 0.52, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 1.0))
|
|
scale_curve.add_point(Vector2(0.6, 1.0))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.92
|
|
p.amount = 14
|
|
p.lifetime = 0.65
|
|
p.randomness = 0.6
|
|
p.local_coords = false
|
|
p.direction = (back_dir + Vector3(0.0, 0.35, 0.0)).normalized()
|
|
p.spread = 45.0
|
|
p.gravity = Vector3(0.0, -9.8, 0.0)
|
|
p.initial_velocity_min = 5.0
|
|
p.initial_velocity_max = 14.0
|
|
p.scale_amount_min = 0.5
|
|
p.scale_amount_max = 1.8
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = origin
|
|
p.restart()
|
|
_free_after(p, 2.0)
|
|
|
|
|
|
func _spawn_kick_flash(origin: Vector3) -> void:
|
|
var mat := _unshaded_material()
|
|
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
|
|
|
|
var sphere := _particle_sphere(1.0, 8, 4, mat)
|
|
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = sphere
|
|
mi.material_override = mat
|
|
mi.scale = Vector3(0.05, 0.05, 0.05)
|
|
get_parent().add_child(mi)
|
|
mi.global_position = origin
|
|
|
|
var tween := create_tween()
|
|
tween.set_parallel(true)
|
|
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
|
|
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
|
|
var fade := func(a: float) -> void: mat.albedo_color.a = a
|
|
tween.tween_method(fade, 0.92, 0.0, 0.14)\
|
|
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
|
tween.tween_callback(mi.queue_free).set_delay(0.16)
|
|
|
|
|
|
func _spawn_dash_burst() -> void:
|
|
var box := BoxMesh.new()
|
|
box.size = Vector3(0.045, 0.045, 0.30)
|
|
box.material = _unshaded_material()
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0))
|
|
ramp.set_color(1, Color(0.50, 0.82, 1.0, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 1.0))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.92
|
|
p.amount = 18
|
|
p.lifetime = 0.22
|
|
p.randomness = 0.15
|
|
p.local_coords = false
|
|
p.direction = _dash_dir
|
|
p.spread = 15.0
|
|
p.gravity = Vector3.ZERO
|
|
p.initial_velocity_min = 18.0
|
|
p.initial_velocity_max = 35.0
|
|
p.scale_amount_min = 0.8
|
|
p.scale_amount_max = 2.8
|
|
p.mesh = box
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
|
|
p.restart()
|
|
_free_after(p, 0.6)
|
|
|
|
|
|
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
|
|
var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0))
|
|
ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 1.0))
|
|
scale_curve.add_point(Vector2(0.5, 0.6))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.95
|
|
p.amount = 22
|
|
p.lifetime = 0.28
|
|
p.randomness = 0.4
|
|
p.local_coords = false
|
|
p.direction = (back_dir + Vector3(0.0, 0.5, 0.0)).normalized()
|
|
p.spread = 30.0
|
|
p.gravity = Vector3(0.0, -14.0, 0.0)
|
|
p.initial_velocity_min = 8.0
|
|
p.initial_velocity_max = 18.0
|
|
p.scale_amount_min = 0.6
|
|
p.scale_amount_max = 2.0
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = origin
|
|
p.restart()
|
|
_free_after(p, 1.0)
|
|
|
|
|
|
func _make_slam_ring_mesh() -> ArrayMesh:
|
|
const INNER_R: float = 0.82
|
|
const OUTER_R: float = 1.00
|
|
const SEGMENTS: int = 48
|
|
|
|
var verts := PackedVector3Array()
|
|
var indices := PackedInt32Array()
|
|
|
|
for i: int in SEGMENTS:
|
|
var a0 := i / float(SEGMENTS) * TAU
|
|
var a1 := (i + 1) / float(SEGMENTS) * TAU
|
|
var vi := i * 4
|
|
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
|
|
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
|
|
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
|
|
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
|
|
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
|
|
|
|
var arrays: Array = []
|
|
arrays.resize(Mesh.ARRAY_MAX)
|
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
|
arrays[Mesh.ARRAY_INDEX] = indices
|
|
|
|
var mesh := ArrayMesh.new()
|
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
|
return mesh
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
# ── Dead bull ─────────────────────────────────────────────────────────────
|
|
# The run is over the instant HP hits zero, but the lose screen holds off a
|
|
# beat before it appears (see HUD._show_game_over). Freeze the carcass through
|
|
# that delay — no input, no abilities, just coast to a stop under gravity — so a
|
|
# dead bull can't keep charging around while the screen is pending.
|
|
if _dead:
|
|
velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta)
|
|
velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta)
|
|
if not is_on_floor():
|
|
velocity += get_gravity() * delta
|
|
move_and_slide()
|
|
return
|
|
|
|
# ── Gravity ───────────────────────────────────────────────────────────────
|
|
if not is_on_floor():
|
|
var gravity := get_gravity()
|
|
# Crash a slam down hard: full leap on the way up, accelerated descent so
|
|
# the bull spends less time floating before the impact.
|
|
if _slam_pending and velocity.y < 0.0:
|
|
gravity *= DP.f("slam_fall_mult")
|
|
velocity += gravity * delta
|
|
|
|
# ── Camera-relative input direction ───────────────────────────────────────
|
|
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
|
var cam_basis := camera_pivot.global_transform.basis
|
|
var flat_forward := -Vector3(cam_basis.z.x, 0.0, cam_basis.z.z).normalized()
|
|
var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized()
|
|
var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized()
|
|
|
|
var on_floor := is_on_floor()
|
|
|
|
# ── Ability cooldowns ────────────────────────────────────────────────────────
|
|
_hit_iframes = maxf(0.0, _hit_iframes - delta)
|
|
for i: int in 4:
|
|
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
|
|
if _ability_active:
|
|
_ability_timer -= delta
|
|
if _ability_timer <= 0.0:
|
|
if _active_ability == 3:
|
|
_end_roll() # tears down roll-specific state (sparks, tumble control)
|
|
else:
|
|
_ability_active = false
|
|
_active_ability = -1
|
|
_legs.set(&"charge_pitch_target", 0.0)
|
|
_legs.set(&"tail_ragdoll", false)
|
|
_slam_pending = false
|
|
_play_idle()
|
|
|
|
# ── Boulder roll ──────────────────────────────────────────────────────────
|
|
# While rolling, the ability owns movement entirely (its own accel, low friction
|
|
# and wall ricochet) — bypass the normal input/kick/damping path below.
|
|
if _active_ability == 3 and _ability_active:
|
|
_tick_roll(delta, direction)
|
|
return
|
|
|
|
_was_on_floor = on_floor
|
|
|
|
# ── Ground locomotion — smooth accelerate / friction ──────────────────────
|
|
# WASD/arrows drive the bull directly: hold a direction to ramp up to max_speed
|
|
# and steer, release to coast to a stop. Ability bursts (dash) and wall bounces
|
|
# can exceed max_speed — while a direction is held we steer that momentum without
|
|
# braking it (cap tracks the current speed); friction only bleeds the excess once
|
|
# input is released.
|
|
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
|
|
var max_speed := DP.f("max_speed")
|
|
if _active_ability == 1 and _ability_active:
|
|
# Dash: leave the barrel fast like a bullet, then bleed exponentially back
|
|
# down to cruise speed (fastest drop right at the start), so it settles into
|
|
# normal running by the time the clip ends instead of holding top speed.
|
|
_dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta))
|
|
flat_vel = _dash_dir * _dash_speed_cur
|
|
elif direction.length_squared() > 0.01:
|
|
var cap := maxf(max_speed, flat_vel.length())
|
|
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
|
|
else:
|
|
flat_vel = flat_vel.move_toward(Vector3.ZERO, DP.f("move_friction") * delta)
|
|
velocity.x = flat_vel.x
|
|
velocity.z = flat_vel.z
|
|
|
|
# ── Visual rotation follows velocity direction ─────────────────────────────
|
|
var flat_speed := Vector2(velocity.x, velocity.z).length()
|
|
if flat_speed > 0.4:
|
|
var target_angle := atan2(velocity.x, velocity.z)
|
|
cube_guy.rotation.y = lerp_angle(
|
|
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
|
|
|
|
# Looping WALK clip while genuinely moving on the ground, idle otherwise.
|
|
_update_locomotion_anim(on_floor and flat_speed > 1.0)
|
|
|
|
_pre_slide_vel_y = velocity.y
|
|
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
|
|
move_and_slide()
|
|
|
|
|
|
|
|
# ── Wall bounce — no sliding, guaranteed exit ────────────────────────────
|
|
# pre_wall_vel is captured before move_and_slide strips the into-wall component,
|
|
# so into_spd correctly reflects how hard the bull hit the wall even when stationary.
|
|
for i in get_slide_collision_count():
|
|
var col := get_slide_collision(i)
|
|
var normal := col.get_normal()
|
|
if abs(normal.y) < 0.5:
|
|
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
|
|
var into_spd := -pre_wall_vel.dot(flat_n)
|
|
var exit_spd := maxf(into_spd * DP.f("wall_bounce_restitution"),
|
|
DP.f("wall_min_bounce_speed"))
|
|
velocity.x = flat_n.x * exit_spd
|
|
velocity.z = flat_n.z * exit_spd
|
|
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
|
|
|
|
# --- ADD THIS BREAKABLE BARREL DETECTION CODE ---
|
|
var collision = get_slide_collision(i)
|
|
var collider = collision.get_collider()
|
|
|
|
# Check if the object we hit has the "destroy_barrel" function
|
|
if collider and collider.has_method("destroy_barrel"):
|
|
# Optional: Only break if moving fast (e.g., sprinting or dashing)
|
|
# if velocity.length() > 5.0:
|
|
collider.destroy_barrel()
|
|
|
|
# ── Landing. Don't bounce ─────────────────────────────────────────────────────
|
|
if is_on_floor() and not _was_on_floor:
|
|
velocity.y = 0.0
|
|
#Bouncing landing below
|
|
#var impact := -_pre_slide_vel_y
|
|
#if impact > DP.f("bounce_min_impact") and not _slam_pending:
|
|
#velocity.y = impact * DP.f("bounce_restitution")
|
|
|
|
# ── Slam landing ──────────────────────────────────────────────────────────
|
|
if _slam_pending and is_on_floor() and not _was_on_floor:
|
|
_slam_pending = false
|
|
_ability_active = false
|
|
_active_ability = -1
|
|
_ability_timer = 0.0
|
|
_legs.set(&"charge_pitch_target", 0.0)
|
|
_legs.set(&"tail_ragdoll", false)
|
|
_play_idle()
|
|
_spawn_slam_fx()
|
|
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
|
|
_smash_barrels_in_radius(DP.f("slam_range"))
|
|
|
|
# ── Dust ──────────────────────────────────────────────────────────────────
|
|
if flat_speed > DP.f("dust_charge_spd") and on_floor:
|
|
_set_dust_state(DustState.CHARGE)
|
|
elif flat_speed > DP.f("dust_walk_spd") and on_floor:
|
|
_set_dust_state(DustState.WALK)
|
|
else:
|
|
_set_dust_state(DustState.NONE)
|
|
|
|
# ── Charge trail direction ────────────────────────────────────────────────
|
|
if _dust_state == DustState.CHARGE and _charge_trail_emitter:
|
|
var vel_flat := Vector3(velocity.x, 0.0, velocity.z)
|
|
if vel_flat.length_squared() > 0.1:
|
|
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
|
|
|
|
# ── Hoof sounds ───────────────────────────────────────────────────────────
|
|
|
|
|
|
func get_hp() -> int:
|
|
return _hp
|
|
|
|
|
|
func get_max_hp() -> int:
|
|
return _max_hp
|
|
|
|
|
|
# Bonus pips currently on the bar — the HUD colours this many top pips yellow.
|
|
func get_bonus_hp() -> int:
|
|
return _bonus_hp
|
|
|
|
|
|
# Award extra health (from a lobby reward): grows both the cap and the current bar so the
|
|
# new pips arrive full, and marks them as bonus so the HUD paints them yellow.
|
|
func grant_bonus_hp(amount: int) -> void:
|
|
if amount <= 0:
|
|
return
|
|
_bonus_hp += amount
|
|
_max_hp += amount
|
|
_hp += amount
|
|
health_changed.emit(_hp, _max_hp)
|
|
|
|
|
|
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
|
|
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
|
|
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
|
|
# not the whole bar; `_dead` latches so the lose screen is raised exactly once.
|
|
func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void:
|
|
if _dead or _hit_iframes > 0.0:
|
|
return
|
|
_hp -= 1
|
|
_hit_flash()
|
|
_hit_iframes = DP.f("bull_hit_iframes")
|
|
health_changed.emit(_hp, _max_hp)
|
|
hit_taken.emit(cause)
|
|
camera_pivot.call(&"trigger_hit", 0.7)
|
|
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
|
|
Input.start_joy_vibration(0, 0.5, 1, 0.2)
|
|
|
|
# A spear gores a spot — blood spurts from the entry wound (where it struck the
|
|
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
|
if cause == "thrown" and Settings.gore:
|
|
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
|
if _huff_player:
|
|
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
|
_huff_player.play()
|
|
if _hp <= 0:
|
|
if _bull_anim.current_animation != _ANIM_DEATH:
|
|
_bull_anim.speed_scale = 0.8
|
|
_bull_anim.play(_ANIM_DEATH)
|
|
_dead = true
|
|
died.emit(cause)
|
|
|
|
|
|
# Pop the bull upward — a smash hitbox knocks it off its feet. Only ever raises the
|
|
# vertical speed so a downward-moving bull still gets launched.
|
|
func apply_knock_up(v: float) -> void:
|
|
velocity.y = maxf(velocity.y, v)
|
|
|
|
|
|
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
|
|
# entry point, biased outward from the bull's centre (so it reads as bursting from the
|
|
# hide) with a touch of lift before gravity drags it down.
|
|
func _spawn_blood(hit_pos: Vector3) -> void:
|
|
var out := hit_pos - global_position
|
|
out.y = 0.0
|
|
out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0)
|
|
var dir := (out + Vector3.UP * 0.8).normalized()
|
|
|
|
var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material())
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0))
|
|
ramp.set_color(1, Color(0.28, 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.7))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
var p := CPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 0.9
|
|
p.amount = 34
|
|
p.lifetime = 0.7
|
|
p.randomness = 0.5
|
|
p.local_coords = false
|
|
p.direction = dir
|
|
p.spread = 42.0
|
|
p.gravity = Vector3(0.0, -14.0, 0.0)
|
|
p.initial_velocity_min = 3.5
|
|
p.initial_velocity_max = 8.0
|
|
p.scale_amount_min = 0.5
|
|
p.scale_amount_max = 1.6
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
get_parent().add_child(p)
|
|
p.global_position = hit_pos
|
|
p.restart()
|
|
_free_after(p, 1.5)
|
|
|
|
|
|
#hit flashing
|
|
func _hit_flash() -> void:
|
|
var meshes := find_children("*", "MeshInstance3D", true, false)
|
|
|
|
for node in meshes:
|
|
var mesh := node as MeshInstance3D
|
|
if mesh:
|
|
mesh.material_overlay = _make_hit_overlay()
|
|
|
|
if _hit_flash_tween:
|
|
_hit_flash_tween.kill()
|
|
|
|
_hit_flash_tween = create_tween()
|
|
_hit_flash_tween.tween_interval(0.08)
|
|
_hit_flash_tween.tween_callback(_clear_hit_flash)
|
|
|
|
|
|
func _make_hit_overlay() -> StandardMaterial3D:
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.albedo_color = Color(1.0, 0.03, 0.03, 1.0)
|
|
return mat
|
|
|
|
|
|
func _clear_hit_flash() -> void:
|
|
var meshes := find_children("*", "MeshInstance3D", true, false)
|
|
|
|
for node in meshes:
|
|
var mesh := node as MeshInstance3D
|
|
if mesh:
|
|
mesh.material_overlay = null
|