Bull roll ability, console screen, tests

This commit is contained in:
2026-07-28 23:10:38 +03:00
parent a0b248748c
commit ebb6ec6335
16 changed files with 1494 additions and 283 deletions
+148 -16
View File
@@ -8,6 +8,14 @@ const HOOF_OFFSETS: Array = [
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_ROLL: StringName = &"Armature|ROLL"
@onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull
@@ -23,12 +31,13 @@ var _charge_was_full: bool = false
var _slam_ring_mesh: ArrayMesh = null
# Ability system — kick=0, slam=1, dash=2
var ability_cd: Array[float] = [0.0, 0.0, 0.0]
# Ability system — kick=0, slam=1, dash=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 _roll_dir: Vector3 = Vector3.ZERO
var _bull_anim: AnimationPlayer = null
var _charge_ready_emitter: CPUParticles3D = null
@@ -68,6 +77,7 @@ func _ready() -> void:
_setup_charge_bar()
_setup_audio()
_bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle()
DP.any_changed.connect(_on_dp_changed)
_roll_damping = DP.f("roll_damping")
@@ -339,6 +349,28 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
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 and ROLL both loop as continuous states; the ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _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:
if _bull_anim == null:
return
_bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.2)
func _unhandled_input(event: InputEvent) -> void:
# Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown.
@@ -348,6 +380,8 @@ func _unhandled_input(event: InputEvent) -> void:
_activate_slam()
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0:
_activate_dash()
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0:
_activate_roll()
func ability_cooldown_fraction(slot: int) -> float:
@@ -356,6 +390,7 @@ func ability_cooldown_fraction(slot: int) -> float:
0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("slam_cooldown")
2: maxcd = DP.f("dash_cooldown")
3: maxcd = DP.f("roll_cooldown")
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
@@ -370,9 +405,9 @@ func _activate_kick() -> void:
_ability_timer = 0.6
if _bull_anim:
_bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"):
_bull_anim.play(&"Armature|FOOTKICK")
_ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length
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)
@@ -393,10 +428,10 @@ func _activate_slam() -> void:
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(&"Armature|SLAM"):
var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length
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(&"Armature|SLAM")
_bull_anim.play(_ANIM_SLAM)
func _activate_dash() -> void:
@@ -406,9 +441,9 @@ func _activate_dash() -> void:
_ability_timer = 0.45
if _bull_anim:
_bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"):
_bull_anim.play(&"Armature|DASH")
_ability_timer = _bull_anim.get_animation(&"Armature|DASH").length
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()
velocity.x = _dash_dir.x * DP.f("dash_speed")
@@ -420,6 +455,98 @@ func _activate_dash() -> void:
_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")
# Combine with any charge already underway: keep the current heading + speed,
# floored to a launch minimum so even a standing roll shoves off like a boulder.
var flat := Vector3(velocity.x, 0.0, velocity.z)
if 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 := maxf(flat.length(), DP.f("roll_min_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
_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: steer, ramp/retain speed, move, then ricochet off walls and
# bowl through matadors. Fully replaces the normal locomotion path while active.
func _tick_roll(delta: float, steer: Vector3) -> void:
var steer_flat := Vector3(steer.x, 0.0, steer.z)
if steer_flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized()
# Ramp toward the cruise top speed; anything above it (from wall boosts) bleeds
# off slowly via roll_momentum instead of snapping back — that's the fun carry.
var speed := Vector2(velocity.x, velocity.z).length()
var top := DP.f("roll_max_speed")
if speed < top:
speed = move_toward(speed, top, DP.f("roll_accel") * delta)
else:
speed = maxf(top, speed * pow(DP.f("roll_momentum"), delta))
speed = minf(speed, DP.f("roll_wall_cap"))
velocity.x = _roll_dir.x * speed
velocity.z = _roll_dir.z * speed
cube_guy.rotation.y = lerp_angle(cube_guy.rotation.y, atan2(_roll_dir.x, _roll_dir.z), delta * 12.0)
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.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
var pre_wall := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide()
_was_on_floor = is_on_floor()
_roll_wall_ricochet(pre_wall)
# Bowling strike: fling matadors we plow into up and outward like pins (and the
# resulting kills feed the HUD combo meter).
_hit_matadors_radius(DP.f("roll_hit_radius"), DP.f("roll_hit_strength"), DP.f("roll_hit_up"))
# On hitting a wall mid-roll, mirror the heading and BOOST speed (capped) instead of
# bleeding it — banking off walls is how you build ludicrous momentum.
func _roll_wall_ricochet(pre_wall: Vector3) -> void:
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if absf(normal.y) >= 0.5:
continue
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
var refl := pre_wall - 2.0 * pre_wall.dot(flat_n) * flat_n
refl.y = 0.0
if refl.length() < 0.01:
return
_roll_dir = refl.normalized()
var boosted := minf(pre_wall.length() * DP.f("roll_wall_boost"), DP.f("roll_wall_cap"))
velocity.x = _roll_dir.x * boosted
velocity.z = _roll_dir.z * boosted
var hit := col.get_position()
_spawn_kick_sparks(hit, flat_n)
_spawn_kick_flash(hit)
camera_pivot.call(&"trigger_hit", clampf(boosted / 90.0, 0.25, 1.0))
if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play()
return
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
@@ -807,7 +934,7 @@ func _physics_process(delta: float) -> void:
var on_floor := is_on_floor()
# ── Ability cooldowns ────────────────────────────────────────────────────────
for i: int in 3:
for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active:
_ability_timer -= delta
@@ -817,8 +944,14 @@ func _physics_process(delta: float) -> void:
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_slam_pending = false
if _bull_anim:
_bull_anim.speed_scale = 1.0
_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
# ── Mouse button single-thrust actions ───────────────────────────────────────
# Left only → thrust NE (forward-right diagonal)
@@ -911,8 +1044,7 @@ func _physics_process(delta: float) -> void:
_ability_timer = 0.0
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
if _bull_anim:
_bull_anim.speed_scale = 1.0
_play_idle()
_spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))