458 lines
17 KiB
GDScript
458 lines
17 KiB
GDScript
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
|
|
|
|
# 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_overlay: ColorRect = null
|
|
var _charge_ready_emitter: CPUParticles3D = null
|
|
|
|
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
|
|
|
|
# Screen overlay — red tint while charge-ahead (both buttons) is active
|
|
var ui := CanvasLayer.new()
|
|
add_child(ui)
|
|
_charge_overlay = ColorRect.new()
|
|
_charge_overlay.color = Color(0.9, 0.55, 0.0, 0.0)
|
|
_charge_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_charge_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
ui.add_child(_charge_overlay)
|
|
|
|
# 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
|
|
_hit_matadors_cone(DP.f("kick_range"), deg_to_rad(60.0), DP.f("kick_strength"))
|
|
|
|
|
|
func _activate_slam() -> void:
|
|
ability_cd[1] = DP.f("slam_cooldown")
|
|
_ability_active = true
|
|
_active_ability = 1
|
|
_ability_timer = 0.9
|
|
if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"):
|
|
_bull_anim.play(&"Armature|SLAM")
|
|
_ability_timer = _bull_anim.get_animation(&"Armature|SLAM").length
|
|
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"))
|
|
|
|
|
|
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) -> void:
|
|
var raw := cube_guy.global_transform.basis.z
|
|
var forward := 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 _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)
|
|
|
|
# ── 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")
|
|
|
|
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
|
|
|
|
# Briefly flash overlay when dash is active
|
|
var target_alpha := 0.20 if (_active_ability == 2 and _ability_active) else 0.0
|
|
var oc := _charge_overlay.color
|
|
_charge_overlay.color = Color(oc.r, oc.g, oc.b, lerpf(oc.a, target_alpha, delta * 12.0))
|
|
|
|
# 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")
|
|
|
|
# ── 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 ───────────────────────────────────────────────────────────
|