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), ] @onready var camera_pivot: Node3D = $Camera3D @onready var cube_guy: Node3D = $bull var _hoof_emitters: Array[CPUParticles3D] = [] var _legs: Node var _kick_timer: float = 0.0 var _was_on_floor: bool = false var _pre_slide_vel_y: float = 0.0 var _thrust_left_charge: float = 0.0 var _thrust_right_charge: float = 0.0 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] var _ability_active: bool = false var _ability_timer: float = 0.0 var _active_ability: int = -1 var _dash_dir: Vector3 = Vector3.ZERO var _bull_anim: AnimationPlayer = null var _charge_ready_emitter: CPUParticles3D = null var _slam_pending: bool = false var _huff_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null var _huff_timer: float = 0.0 var _roll_damping: float = 0.15 enum DustState { NONE, WALK, CHARGE } var _dust_state: DustState = DustState.NONE func _ready() -> void: add_to_group(&"player") _setup_hoof_dust() _setup_legs() _setup_charge_indicators() _setup_audio() _bull_anim = _find_anim_player(cube_guy) DP.any_changed.connect(_on_dp_changed) _roll_damping = DP.f("roll_damping") 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 mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.10 sphere.height = 0.20 sphere.radial_segments = 4 sphere.rings = 2 sphere.material = mat var ramp := Gradient.new() ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) ramp.set_color(1, Color(0.80, 0.69, 0.46, 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 = ramp p.scale_amount_curve = scale_curve cube_guy.add_child(p) _hoof_emitters.append(p) func _setup_charge_indicators() -> void: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.07 sphere.height = 0.14 sphere.radial_segments = 4 sphere.rings = 2 sphere.material = mat # Orange spark burst when single-button charge hits max var burst_ramp := Gradient.new() burst_ramp.set_color(0, Color(1.0, 0.55, 0.0, 1.0)) burst_ramp.set_color(1, Color(1.0, 0.15, 0.0, 0.0)) var burst_scale := Curve.new() burst_scale.add_point(Vector2(0.0, 1.0)) burst_scale.add_point(Vector2(1.0, 0.0)) _charge_ready_emitter = CPUParticles3D.new() _charge_ready_emitter.position = Vector3(0.0, 0.3, 0.0) _charge_ready_emitter.emitting = false _charge_ready_emitter.one_shot = true _charge_ready_emitter.amount = 16 _charge_ready_emitter.lifetime = 0.5 _charge_ready_emitter.explosiveness = 1.0 _charge_ready_emitter.randomness = 0.4 _charge_ready_emitter.local_coords = false _charge_ready_emitter.direction = Vector3.UP _charge_ready_emitter.spread = 120.0 _charge_ready_emitter.initial_velocity_min = 2.5 _charge_ready_emitter.initial_velocity_max = 5.0 _charge_ready_emitter.scale_amount_min = 0.2 _charge_ready_emitter.scale_amount_max = 0.5 _charge_ready_emitter.mesh = sphere _charge_ready_emitter.color_ramp = burst_ramp _charge_ready_emitter.scale_amount_curve = burst_scale cube_guy.add_child(_charge_ready_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 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 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.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.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 func _on_dp_changed(_key: String, _val: Variant) -> void: _roll_damping = DP.f("roll_damping") 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 func _unhandled_input(event: InputEvent) -> void: if _ability_active: return 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() 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") return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 func _activate_kick() -> void: ability_cd[0] = DP.f("kick_cooldown") _ability_active = true _active_ability = 0 _ability_timer = 0.6 if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"): _bull_anim.play(&"Armature|FOOTKICK") _ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").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[1] = DP.f("slam_cooldown") _ability_active = true _active_ability = 1 _legs.set(&"tail_ragdoll", true) _slam_pending = true var jump_vel := DP.f("slam_jump_velocity") velocity.y = jump_vel var air_time := jump_vel / 9.8 * 2.0 _ability_timer = air_time + 0.5 if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"): var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length _bull_anim.speed_scale = anim_len / maxf(air_time, 0.01) _bull_anim.play(&"Armature|SLAM") func _activate_dash() -> void: ability_cd[2] = DP.f("dash_cooldown") _ability_active = true _active_ability = 2 _ability_timer = 0.45 if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"): _bull_anim.play(&"Armature|DASH") _ability_timer = _bull_anim.get_animation(&"Armature|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") _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 _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 if to_mat.length() > range_m: continue if to_mat.length() > 0.01 and forward.dot(to_mat.normalized()) < 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) -> 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 if to_mat.length() > range_m: continue var away := to_mat.normalized() if to_mat.length() > 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) 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 mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.09 sphere.height = 0.18 sphere.radial_segments = 4 sphere.rings = 2 sphere.material = mat 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() get_tree().create_timer(2.5).timeout.connect(func() -> void: if is_instance_valid(p): p.queue_free() ) 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) camera_pivot.call(&"trigger_hit", 0.35) func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.12 sphere.height = 0.24 sphere.radial_segments = 4 sphere.rings = 2 sphere.material = mat var ramp := Gradient.new() ramp.set_color(0, Color(0.80, 0.68, 0.42, 0.9)) ramp.set_color(1, Color(0.80, 0.68, 0.42, 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() get_tree().create_timer(2.5).timeout.connect(func() -> void: if is_instance_valid(p): p.queue_free() ) func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.055 sphere.height = 0.11 sphere.radial_segments = 3 sphere.rings = 1 sphere.material = mat 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() get_tree().create_timer(2.0).timeout.connect(func() -> void: if is_instance_valid(p): p.queue_free() ) 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 _apply_thrust(dir: Vector3) -> void: var impulse := DP.f("thrust_impulse") velocity.x += dir.x * impulse velocity.z += dir.z * impulse if is_on_floor(): velocity.y += DP.f("thrust_vertical") var flat := Vector2(velocity.x, velocity.z) var spd := flat.length() if spd > DP.f("max_speed"): var s := DP.f("max_speed") / spd velocity.x *= s velocity.z *= s func _apply_kick(direction: Vector3) -> void: var force := DP.f("kick_impulse") * randf_range(1.0 - DP.f("kick_force_var"), 1.0 + DP.f("kick_force_var")) var spread := deg_to_rad(randf_range(-DP.f("kick_spread"), DP.f("kick_spread"))) var kick_dir := direction.rotated(Vector3.UP, spread) velocity.x += kick_dir.x * force velocity.z += kick_dir.z * force if is_on_floor(): velocity.y += DP.f("kick_vertical") * randf_range(0.75, 1.25) var flat := Vector2(velocity.x, velocity.z) var spd := flat.length() if spd > DP.f("max_speed"): var s := DP.f("max_speed") / spd velocity.x *= s velocity.z *= s func _physics_process(delta: float) -> void: # ── Gravity ─────────────────────────────────────────────────────────────── if not is_on_floor(): velocity += get_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 ──────────────────────────────────────────────────────── for i: int in 3: ability_cd[i] = maxf(0.0, ability_cd[i] - delta) if _ability_active: _ability_timer -= delta if _ability_timer <= 0.0: _ability_active = false _active_ability = -1 _legs.set(&"charge_pitch_target", 0.0) _legs.set(&"tail_ragdoll", false) _slam_pending = false if _bull_anim: _bull_anim.speed_scale = 1.0 # ── Mouse button single-thrust actions ─────────────────────────────────────── # Left only → thrust NE (forward-right diagonal) # Right only → thrust NW (forward-left diagonal) var left_held := Input.is_action_pressed(&"thrust_left") var right_held := Input.is_action_pressed(&"thrust_right") camera_pivot.call(&"set_charge_active", left_held and right_held) if left_held and right_held: _thrust_left_charge = 0.0 _thrust_right_charge = 0.0 else: if left_held: _thrust_left_charge = minf(_thrust_left_charge + delta, DP.f("thrust_charge_time")) elif Input.is_action_just_released(&"thrust_left") and _thrust_left_charge > 0.0: var t := _thrust_left_charge / DP.f("thrust_charge_time") var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t) var raw := cube_guy.global_transform.basis.z _apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, -angle)) _thrust_left_charge = 0.0 if right_held: _thrust_right_charge = minf(_thrust_right_charge + delta, DP.f("thrust_charge_time")) elif Input.is_action_just_released(&"thrust_right") and _thrust_right_charge > 0.0: var t := _thrust_right_charge / DP.f("thrust_charge_time") var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t) var raw := cube_guy.global_transform.basis.z _apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, angle)) _thrust_right_charge = 0.0 # Fire spark burst the frame either single-button charge first hits max var max_charge := DP.f("thrust_charge_time") var either_full := _thrust_left_charge >= max_charge or _thrust_right_charge >= max_charge if either_full and not _charge_was_full: _charge_ready_emitter.restart() _charge_was_full = either_full # Reset kick timer on landing so the first kick after a bounce is immediate if on_floor and not _was_on_floor and direction: _kick_timer = 0.0 _was_on_floor = on_floor # ── Periodic kick ───────────────────────────────────────────────────────── _kick_timer = maxf(0.0, _kick_timer - delta) if direction and _kick_timer <= 0.0: _apply_kick(direction) _kick_timer = DP.f("kick_interval") # ── Rolling friction (exponential, frame-rate independent) ──────────────── var retain := pow(_roll_damping, delta) velocity.x *= retain velocity.z *= retain # ── 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")) _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) # ── Bounce on landing ───────────────────────────────────────────────────── if is_on_floor() and not _was_on_floor: 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) if _bull_anim: _bull_anim.speed_scale = 1.0 _spawn_slam_fx() _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength")) # ── 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) # ── Hoof sounds ───────────────────────────────────────────────────────────