Release 0.2.0: device-aware controls, spear, settings, shaders

Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:39:37 +03:00
parent ce6f3d7075
commit 56593c58cf
35 changed files with 1885 additions and 242 deletions
+239 -42
View File
@@ -27,16 +27,23 @@ var _pre_slide_vel_y: float = 0.0
var _slam_ring_mesh: ArrayMesh = null
# Ability system — kick=0, slam=1, dash=2, roll=3
# 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
@@ -49,6 +56,17 @@ var _cube_scale: Vector3 = Vector3.ONE
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
var _hit_iframes: float = 0.0
var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0
@@ -61,6 +79,8 @@ var _charge_dust_ramp: Gradient = null
func _ready() -> void:
add_to_group(&"player")
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp
_cube_scale = cube_guy.scale
_setup_hoof_dust()
_setup_legs()
@@ -258,8 +278,9 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
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]:
# 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()
@@ -268,6 +289,7 @@ func _setup_bull_idle() -> void:
# 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
@@ -275,25 +297,56 @@ func _play_idle() -> void:
_bull_anim.play(_ANIM_IDLE, 0.2)
# 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_DASH):
_bull_anim.speed_scale = 1.0
_bull_anim.play(_ANIM_DASH, 0.25)
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") and ability_cd[0] <= 0.0:
_activate_kick()
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
_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()
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("slam_cooldown")
2: maxcd = DP.f("dash_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
@@ -306,9 +359,9 @@ func _activate_kick() -> void:
ability_cd[0] = DP.f("kick_cooldown")
_ability_active = true
_active_ability = 0
_ability_timer = 0.6
_ability_timer = 0.3
if _bull_anim:
_bull_anim.speed_scale = 1.0
_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
@@ -321,11 +374,12 @@ func _activate_kick() -> void:
func _activate_slam() -> void:
ability_cd[1] = DP.f("slam_cooldown")
ability_cd[2] = DP.f("slam_cooldown")
_ability_active = true
_active_ability = 1
_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)).
@@ -339,9 +393,9 @@ func _activate_slam() -> void:
func _activate_dash() -> void:
ability_cd[2] = DP.f("dash_cooldown")
ability_cd[1] = DP.f("dash_cooldown")
_ability_active = true
_active_ability = 2
_active_ability = 1
_ability_timer = 0.45
if _bull_anim:
_bull_anim.speed_scale = 1.0
@@ -350,8 +404,9 @@ func _activate_dash() -> void:
_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")
velocity.z = _dash_dir.z * DP.f("dash_speed")
_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:
@@ -366,10 +421,16 @@ func _activate_roll() -> void:
_ability_timer = DP.f("roll_duration")
_roll_ramp = 0.0
_roll_spin = 0.0
# Curl into the ball along the current heading (or facing when standing still)
# and launch at the base speed — it ramps up from here the longer you roll.
_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 flat.length() > 1.0:
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
@@ -392,14 +453,20 @@ func _activate_roll() -> void:
_huff_player.play()
# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer
# the roll lasts, move (walls just slide), then pop the ball on the first matador we
# ram. 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()
# 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.
@@ -431,25 +498,64 @@ func _tick_roll(delta: float, steer: Vector3) -> void:
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
# outward, then end the roll (Rammus stops when Powerball connects). Returns true if
# it popped so the caller can stop touching roll state this frame.
# 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 active matador we haven't already popped this roll, or null if none left.
func _roll_pick_target() -> Node3D:
var best: Node3D = null
var best_d := INF
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if _roll_hit.has(mat) or not _mat_active(mat):
continue
var d: float = (mat as Node3D).global_position.distance_squared_to(global_position)
if d < best_d:
best_d = d
best = mat as Node3D
return best
func _roll_target_valid() -> bool:
return is_instance_valid(_roll_target) and _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")
# 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:
@@ -457,6 +563,9 @@ func _end_roll() -> void:
_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)
@@ -805,6 +914,19 @@ func _make_slam_ring_mesh() -> ArrayMesh:
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()
@@ -824,6 +946,7 @@ func _physics_process(delta: float) -> void:
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:
@@ -856,7 +979,13 @@ func _physics_process(delta: float) -> void:
# input is released.
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
var max_speed := DP.f("max_speed")
if direction.length_squared() > 0.01:
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:
@@ -871,6 +1000,9 @@ func _physics_process(delta: float) -> void:
cube_guy.rotation.y = lerp_angle(
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
# Looping DASH 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()
@@ -925,11 +1057,76 @@ func _physics_process(delta: float) -> void:
# ── Hoof sounds ───────────────────────────────────────────────────────────
# A single clean hit — thrown or swung blade — ends the run. `cause` ("gored" /
# "thrown") tags how it happened for the death notice. Latches so the lose screen is
# raised exactly once even if several blades land on the same frame.
func take_sword_hit(cause: String = "") -> void:
if _dead:
func get_hp() -> int:
return _hp
func get_max_hp() -> int:
return _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
_dead = true
died.emit(cause)
_hp -= 1
_hit_iframes = DP.f("bull_hit_iframes")
health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7)
# 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:
_dead = true
died.emit(cause)
# 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)