using System; using UnityEngine; using System.Collections.Generic; using UnityEngine.VFX; namespace SRL { public class ArcadeKart : MonoBehaviour { [System.Serializable] public class StatPowerup { public ArcadeKart.Stats modifiers; public string PowerUpID; public float ElapsedTime; public float MaxTime; } [System.Serializable] public struct Stats { [Header("Movement Settings")] [Min(0.001f), Tooltip("Top speed attainable when moving forward.")] public float TopSpeed; [Tooltip("How quickly the kart reaches top speed.")] public float Acceleration; [Min(0.001f), Tooltip("Top speed attainable when moving backward.")] public float ReverseSpeed; [Tooltip("How quickly the kart reaches top speed, when moving backward.")] public float ReverseAcceleration; [Tooltip("How quickly the kart starts accelerating from 0. A higher number means it accelerates faster sooner.")] [Range(0.2f, 1)] public float AccelerationCurve; [Tooltip("How quickly the kart slows down when the brake is applied.")] public float Braking; [Tooltip("How quickly the kart will reach a full stop when no inputs are made.")] public float CoastingDrag; [Range(0.0f, 1.0f)] [Tooltip("The amount of side-to-side friction.")] public float Grip; [Tooltip("The target height to float above the ground.")] public float floatHeight; [Tooltip("The minimum height to float above the ground.")] public float minimumFloatHeight; [Tooltip("How tightly the kart can turn left or right.")] public float Steer; [Tooltip("Additional gravity for when the kart is in the air.")] public float AddedGravity; // allow for stat adding for powerups. public static Stats operator +(Stats a, Stats b) { return new Stats { Acceleration = a.Acceleration + b.Acceleration, AccelerationCurve = a.AccelerationCurve + b.AccelerationCurve, Braking = a.Braking + b.Braking, CoastingDrag = a.CoastingDrag + b.CoastingDrag, AddedGravity = a.AddedGravity + b.AddedGravity, Grip = a.Grip + b.Grip, floatHeight = a.floatHeight + b.floatHeight, minimumFloatHeight = a.minimumFloatHeight + b.minimumFloatHeight, ReverseAcceleration = a.ReverseAcceleration + b.ReverseAcceleration, ReverseSpeed = a.ReverseSpeed + b.ReverseSpeed, TopSpeed = a.TopSpeed + b.TopSpeed, Steer = a.Steer + b.Steer, }; } } public Rigidbody Rigidbody { get; private set; } public InputData Input { get; private set; } public float AirPercent { get; private set; } public float GroundPercent { get; private set; } public ArcadeKart.Stats baseStats = new ArcadeKart.Stats { TopSpeed = 10f, Acceleration = 5f, AccelerationCurve = 4f, Braking = 10f, ReverseAcceleration = 5f, ReverseSpeed = 5f, Steer = 5f, CoastingDrag = 4f, Grip = .95f, floatHeight = 1f, minimumFloatHeight = .5f, AddedGravity = 1f, }; [Header("Vehicle Visual")] public List m_VisualWheels; [Header("Vehicle Physics")] [Tooltip("The transform that determines the position of the kart's mass.")] public Transform CenterOfMass; [Range(0.0f, 20.0f), Tooltip("Coefficient used to reorient the kart in the air. The higher the number, the faster the kart will readjust itself along the horizontal plane.")] public float AirborneReorientationCoefficient = 3.0f; [Header("Drifting")] [Range(0.01f, 1.0f), Tooltip("The grip value when drifting.")] public float DriftGrip = 0.4f; [Range(0.0f, 10.0f), Tooltip("Additional steer when the kart is drifting.")] public float DriftAdditionalSteer = 5.0f; [Range(1.0f, 30.0f), Tooltip("The higher the angle, the easier it is to regain full grip.")] public float MinAngleToFinishDrift = 10.0f; [Range(0.01f, 0.99f), Tooltip("Mininum speed percentage to switch back to full grip.")] public float MinSpeedPercentToFinishDrift = 0.5f; [Range(1.0f, 20.0f), Tooltip("The higher the value, the easier it is to control the drift steering.")] public float DriftControl = 10.0f; [Range(0.0f, 20.0f), Tooltip("The lower the value, the longer the drift will last without trying to control it by steering.")] public float DriftDampening = 10.0f; [Header("Suspensions")] [Tooltip("The maximum extension possible between the kart's body and the wheels.")] [Range(0.0f, 1.0f)] public float SuspensionHeight = 0.2f; [Range(10.0f, 100000.0f), Tooltip("The higher the value, the stiffer the suspension will be.")] public float SuspensionSpring = 20000.0f; [Range(0.0f, 5000.0f), Tooltip("The higher the value, the faster the kart will stabilize itself.")] public float SuspensionDamp = 500.0f; [Tooltip("Vertical offset to adjust the position of the wheels relative to the kart's body.")] [Range(-1.0f, 1.0f)] public float WheelsPositionVerticalOffset = 0.0f; [Tooltip("Which layers the wheels will detect.")] public LayerMask GroundLayers = Physics.DefaultRaycastLayers; // the input sources that can control the kart IInput[] m_Inputs; const float k_NullInput = 0.01f; const float k_NullSpeed = 0.01f; Vector3 m_VerticalReference = Vector3.up; // Drift params public bool WantsToDrift { get; private set; } = false; public bool IsDrifting { get; private set; } = false; float m_CurrentGrip = 1.0f; float m_DriftTurningPower = 0.0f; float m_PreviousGroundPercent = 1.0f; readonly List<(GameObject trailRoot, WheelCollider wheel, TrailRenderer trail)> m_DriftTrailInstances = new List<(GameObject, WheelCollider, TrailRenderer)>(); readonly List<(WheelCollider wheel, float horizontalOffset, float rotation, ParticleSystem sparks)> m_DriftSparkInstances = new List<(WheelCollider, float, float, ParticleSystem)>(); // can the kart move? bool m_CanMove = true; List m_ActivePowerupList = new List(); ArcadeKart.Stats m_FinalStats; Quaternion m_LastValidRotation; Vector3 m_LastValidPosition; Vector3 m_LastCollisionNormal; bool m_HasCollision; bool m_InAir = false; public void AddPowerup(StatPowerup statPowerup) => m_ActivePowerupList.Add(statPowerup); public void SetCanMove(bool move) => m_CanMove = move; public float GetMaxSpeed() => Mathf.Max(m_FinalStats.TopSpeed, m_FinalStats.ReverseSpeed); void UpdateSuspensionParams(WheelCollider wheel) { wheel.suspensionDistance = SuspensionHeight; wheel.center = new Vector3(0.0f, WheelsPositionVerticalOffset, 0.0f); JointSpring spring = wheel.suspensionSpring; spring.spring = SuspensionSpring; spring.damper = SuspensionDamp; wheel.suspensionSpring = spring; } void Awake() { Rigidbody = GetComponent(); m_Inputs = GetComponents(); m_CurrentGrip = baseStats.Grip; } void FixedUpdate() { GatherInputs(); // apply our powerups to create our finalStats TickPowerups(); // apply our physics properties Rigidbody.centerOfMass = transform.InverseTransformPoint(CenterOfMass.position); int groundedCount = 4; // if (FrontLeftWheel.isGrounded && FrontLeftWheel.GetGroundHit(out WheelHit hit)) // groundedCount++; // if (FrontRightWheel.isGrounded && FrontRightWheel.GetGroundHit(out hit)) // groundedCount++; // if (RearLeftWheel.isGrounded && RearLeftWheel.GetGroundHit(out hit)) // groundedCount++; // if (RearRightWheel.isGrounded && RearRightWheel.GetGroundHit(out hit)) // groundedCount++; // calculate how grounded and airborne we are GroundPercent = (float) groundedCount / 4.0f; AirPercent = 1 - GroundPercent; // apply vehicle physics if (m_CanMove) { MoveVehicle(Input.Accelerate, Input.Brake, Input.TurnInput); } GroundAirbourne(); m_PreviousGroundPercent = GroundPercent; } void GatherInputs() { // reset input Input = new InputData(); WantsToDrift = false; // gather nonzero input from our sources for (int i = 0; i < m_Inputs.Length; i++) { Input = m_Inputs[i].GenerateInput(); WantsToDrift = Input.Brake && Vector3.Dot(Rigidbody.linearVelocity, transform.forward) > 0.0f; } } void TickPowerups() { // remove all elapsed powerups m_ActivePowerupList.RemoveAll((p) => { return p.ElapsedTime > p.MaxTime; }); // zero out powerups before we add them all up var powerups = new Stats(); // add up all our powerups for (int i = 0; i < m_ActivePowerupList.Count; i++) { var p = m_ActivePowerupList[i]; // add elapsed time p.ElapsedTime += Time.fixedDeltaTime; // add up the powerups powerups += p.modifiers; } // add powerups to our final stats m_FinalStats = baseStats + powerups; // clamp values in finalstats m_FinalStats.Grip = Mathf.Clamp(m_FinalStats.Grip, 0, 1); } void GroundAirbourne() { RaycastHit hit; // Check if the object is hitting the ground using a raycast if (Physics.Raycast(transform.position, Vector3.down, out hit, baseStats.floatHeight + 0.1f)) { // Calculate the upward force needed to maintain the desired height float upwardForce = (baseStats.floatHeight - (hit.distance - baseStats.minimumFloatHeight)) * 10f; Rigidbody.AddForce(Vector3.up * upwardForce, ForceMode.Acceleration); } } public void Reset() { Vector3 euler = transform.rotation.eulerAngles; euler.x = euler.z = 0f; transform.rotation = Quaternion.Euler(euler); } public float LocalSpeed() { if (m_CanMove) { float dot = Vector3.Dot(transform.forward, Rigidbody.linearVelocity); if (Mathf.Abs(dot) > 0.1f) { float speed = Rigidbody.linearVelocity.magnitude; return dot < 0 ? -(speed / m_FinalStats.ReverseSpeed) : (speed / m_FinalStats.TopSpeed); } return 0f; } else { // use this value to play kart sound when it is waiting the race start countdown. return Input.Accelerate ? 1.0f : 0.0f; } } void OnCollisionEnter(Collision collision) => m_HasCollision = true; void OnCollisionExit(Collision collision) => m_HasCollision = false; void OnCollisionStay(Collision collision) { m_HasCollision = true; m_LastCollisionNormal = Vector3.zero; float dot = -1.0f; foreach (var contact in collision.contacts) { if (Vector3.Dot(contact.normal, Vector3.up) > dot) m_LastCollisionNormal = contact.normal; } } void MoveVehicle(bool accelerate, bool brake, float turnInput) { float accelInput = (accelerate ? 1.0f : 0.0f) - (brake ? 1.0f : 0.0f); // manual acceleration curve coefficient scalar float accelerationCurveCoeff = 5; Vector3 localVel = transform.InverseTransformVector(Rigidbody.linearVelocity); bool accelDirectionIsFwd = accelInput >= 0; bool localVelDirectionIsFwd = localVel.z >= 0; // use the max speed for the direction we are going--forward or reverse. float maxSpeed = localVelDirectionIsFwd ? m_FinalStats.TopSpeed : m_FinalStats.ReverseSpeed; float accelPower = accelDirectionIsFwd ? m_FinalStats.Acceleration : m_FinalStats.ReverseAcceleration; float currentSpeed = Rigidbody.linearVelocity.magnitude; float accelRampT = currentSpeed / maxSpeed; float multipliedAccelerationCurve = m_FinalStats.AccelerationCurve * accelerationCurveCoeff; float accelRamp = Mathf.Lerp(multipliedAccelerationCurve, 1, accelRampT * accelRampT); bool isBraking = (localVelDirectionIsFwd && brake) || (!localVelDirectionIsFwd && accelerate); // if we are braking (moving reverse to where we are going) // use the braking accleration instead float finalAccelPower = isBraking ? m_FinalStats.Braking : accelPower; float finalAcceleration = finalAccelPower * accelRamp; // apply inputs to forward/backward float turningPower = IsDrifting ? m_DriftTurningPower : turnInput * m_FinalStats.Steer; Quaternion turnAngle = Quaternion.AngleAxis(turningPower, transform.up); Vector3 fwd = turnAngle * transform.forward; Vector3 movement = fwd * accelInput * finalAcceleration * ((m_HasCollision || GroundPercent > 0.0f) ? 1.0f : 0.0f); // forward movement bool wasOverMaxSpeed = currentSpeed >= maxSpeed; // if over max speed, cannot accelerate faster. if (wasOverMaxSpeed && !isBraking) movement *= 0.0f; Vector3 newVelocity = Rigidbody.linearVelocity + movement * Time.fixedDeltaTime; newVelocity.y = Rigidbody.linearVelocity.y; // clamp max speed if we are on ground if (GroundPercent > 0.0f && !wasOverMaxSpeed) { newVelocity = Vector3.ClampMagnitude(newVelocity, maxSpeed); } // coasting is when we aren't touching accelerate if (Mathf.Abs(accelInput) < k_NullInput && GroundPercent > 0.0f) { newVelocity = Vector3.MoveTowards(newVelocity, new Vector3(0, Rigidbody.linearVelocity.y, 0), Time.fixedDeltaTime * m_FinalStats.CoastingDrag); } Rigidbody.linearVelocity = newVelocity; // Drift if (GroundPercent > 0.0f) { if (m_InAir) { m_InAir = false; } // manual angular velocity coefficient float angularVelocitySteering = 0.4f; float angularVelocitySmoothSpeed = 20f; // turning is reversed if we're going in reverse and pressing reverse if (!localVelDirectionIsFwd && !accelDirectionIsFwd) angularVelocitySteering *= -1.0f; var angularVel = Rigidbody.angularVelocity; // move the Y angular velocity towards our target angularVel.y = Mathf.MoveTowards(angularVel.y, turningPower * angularVelocitySteering, Time.fixedDeltaTime * angularVelocitySmoothSpeed); // apply the angular velocity Rigidbody.angularVelocity = angularVel; // rotate rigidbody's velocity as well to generate immediate velocity redirection // manual velocity steering coefficient float velocitySteering = 25f; // If the karts lands with a forward not in the velocity direction, we start the drift if (GroundPercent >= 0.0f && m_PreviousGroundPercent < 0.1f) { Vector3 flattenVelocity = Vector3.ProjectOnPlane(Rigidbody.linearVelocity, m_VerticalReference).normalized; if (Vector3.Dot(flattenVelocity, transform.forward * Mathf.Sign(accelInput)) < Mathf.Cos(MinAngleToFinishDrift * Mathf.Deg2Rad)) { IsDrifting = true; m_CurrentGrip = DriftGrip; m_DriftTurningPower = 0.0f; } } // Drift Management if (!IsDrifting) { if ((WantsToDrift || isBraking) && currentSpeed > maxSpeed * MinSpeedPercentToFinishDrift) { IsDrifting = true; m_DriftTurningPower = turningPower + (Mathf.Sign(turningPower) * DriftAdditionalSteer); m_CurrentGrip = DriftGrip; } } if (IsDrifting) { float turnInputAbs = Mathf.Abs(turnInput); if (turnInputAbs < k_NullInput) m_DriftTurningPower = Mathf.MoveTowards(m_DriftTurningPower, 0.0f, Mathf.Clamp01(DriftDampening * Time.fixedDeltaTime)); // Update the turning power based on input float driftMaxSteerValue = m_FinalStats.Steer + DriftAdditionalSteer; m_DriftTurningPower = Mathf.Clamp(m_DriftTurningPower + (turnInput * Mathf.Clamp01(DriftControl * Time.fixedDeltaTime)), -driftMaxSteerValue, driftMaxSteerValue); bool facingVelocity = Vector3.Dot(Rigidbody.linearVelocity.normalized, transform.forward * Mathf.Sign(accelInput)) > Mathf.Cos(MinAngleToFinishDrift * Mathf.Deg2Rad); bool canEndDrift = true; if (isBraking) canEndDrift = false; else if (!facingVelocity) canEndDrift = false; else if (turnInputAbs >= k_NullInput && currentSpeed > maxSpeed * MinSpeedPercentToFinishDrift) canEndDrift = false; if (canEndDrift || currentSpeed < k_NullSpeed) { // No Input, and car aligned with speed direction => Stop the drift IsDrifting = false; m_CurrentGrip = m_FinalStats.Grip; } } // rotate our velocity based on current steer value Rigidbody.linearVelocity = Quaternion.AngleAxis(turningPower * Mathf.Sign(localVel.z) * velocitySteering * m_CurrentGrip * Time.fixedDeltaTime, transform.up) * Rigidbody.linearVelocity; } else { m_InAir = true; } bool validPosition = false; if (Physics.Raycast(transform.position + (transform.up * 0.1f), -transform.up, out RaycastHit hit, 3.0f, 1 << 9 | 1 << 10 | 1 << 11)) // Layer: ground (9) / Environment(10) / Track (11) { Vector3 lerpVector = (m_HasCollision && m_LastCollisionNormal.y > hit.normal.y) ? m_LastCollisionNormal : hit.normal; m_VerticalReference = Vector3.Slerp(m_VerticalReference, lerpVector, Mathf.Clamp01(AirborneReorientationCoefficient * Time.fixedDeltaTime * (GroundPercent > 0.0f ? 10.0f : 1.0f))); // Blend faster if on ground } else { Vector3 lerpVector = (m_HasCollision && m_LastCollisionNormal.y > 0.0f) ? m_LastCollisionNormal : Vector3.up; m_VerticalReference = Vector3.Slerp(m_VerticalReference, lerpVector, Mathf.Clamp01(AirborneReorientationCoefficient * Time.fixedDeltaTime)); } validPosition = GroundPercent > 0.7f && !m_HasCollision && Vector3.Dot(m_VerticalReference, Vector3.up) > 0.9f; // Airborne / Half on ground management if (GroundPercent < 0.7f) { Rigidbody.angularVelocity = new Vector3(0.0f, Rigidbody.angularVelocity.y * 0.98f, 0.0f); Vector3 finalOrientationDirection = Vector3.ProjectOnPlane(transform.forward, m_VerticalReference); finalOrientationDirection.Normalize(); if (finalOrientationDirection.sqrMagnitude > 0.0f) { Rigidbody.MoveRotation(Quaternion.Lerp(Rigidbody.rotation, Quaternion.LookRotation(finalOrientationDirection, m_VerticalReference), Mathf.Clamp01(AirborneReorientationCoefficient * Time.fixedDeltaTime))); } } else if (validPosition) { m_LastValidPosition = transform.position; m_LastValidRotation.eulerAngles = new Vector3(0.0f, transform.rotation.y, 0.0f); } } } }