extends CharacterBody3D # Hoof positions in bull_v10 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 = $SpringArmPivot @onready var cube_guy: Node3D = $bull_v10 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 enum _DustState { NONE, WALK, CHARGE } var _dust_state: _DustState = _DustState.NONE func _ready() -> void: add_to_group(&"player") _setup_hoof_dust() _setup_legs() 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 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 _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: if _hoof_emitters.is_empty() or _dust_state == _DustState.NONE: return var prev := _dust_state _dust_state = _DustState.NONE _set_dust_state(prev) 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, charging: bool) -> void: var base := DP.f("kick_impulse") * (DP.f("charge_kick_mult") if charging else 1.0) # Random force variance — each kick punches a different amount var force := base * randf_range(1.0 - DP.f("kick_force_var"), 1.0 + DP.f("kick_force_var")) # Random angle spread — slight mis-kick like a real ball 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 # Vertical arc — only when grounded so arcs don't chain infinitely in the air if is_on_floor(): velocity.y += DP.f("kick_vertical") * randf_range(0.75, 1.25) # Cap immediately after kick so charge doesn't overshoot by too much var flat := Vector2(velocity.x, velocity.z) var spd := flat.length() var cap := DP.f("max_speed") * (DP.f("charge_kick_mult") if charging else 1.0) if spd > cap: var s := cap / spd velocity.x *= s velocity.z *= s func _physics_process(delta: float) -> void: # ── Gravity & jump ──────────────────────────────────────────────────────── if not is_on_floor(): velocity += get_gravity() * delta if Input.is_action_just_pressed(&"jump") and is_on_floor(): velocity.y = DP.f("jump_velocity") # ── 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 charging := Input.is_action_pressed(&"charge") var on_floor := is_on_floor() # ── Left / right click thrust (only when mouse is captured) ───────────── # Left click → thrust NE (forward-right diagonal) # Right click → thrust NW (forward-left diagonal) if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if Input.is_action_just_pressed(&"thrust_left"): var ne := (flat_forward + flat_right).normalized() _apply_thrust(ne if ne.length() > 0.1 else flat_forward) if Input.is_action_just_pressed(&"thrust_right"): var nw := (flat_forward - flat_right).normalized() _apply_thrust(nw if nw.length() > 0.1 else flat_forward) # 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, charging) _kick_timer = DP.f("kick_interval") # ── Rolling friction (exponential, frame-rate independent) ──────────────── var retain := pow(DP.f("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 move_and_slide() # ── 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)