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 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 _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() _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 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: _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")) # ── 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 # 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) # 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) # 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