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