Building out Golf Level and mission states and improved AI
This commit is contained in:
@@ -5,6 +5,7 @@ using ArcadeVP;
|
||||
public class AIController : MonoBehaviour
|
||||
{
|
||||
public enum AIState { Patrol, Pursue, Searching, Unstuck, Destroyed }
|
||||
public enum AITactic { Chase, Intercept }
|
||||
|
||||
[Header("Target")]
|
||||
public ArcadeVehicleController player;
|
||||
@@ -13,6 +14,8 @@ public class AIController : MonoBehaviour
|
||||
|
||||
[Header("Current State (Debug)")]
|
||||
public AIState currentState = AIState.Patrol;
|
||||
public AITactic currentTactic = AITactic.Chase;
|
||||
private float tacticTimer = 0f;
|
||||
|
||||
[Header("Vision & Senses")]
|
||||
[Tooltip("Maximum distance the AI can spot the player.")]
|
||||
@@ -32,6 +35,10 @@ public class AIController : MonoBehaviour
|
||||
private float searchTimer = 0f;
|
||||
private Vector3 lastKnownPosition;
|
||||
|
||||
// --- POLICE RADIO (HIVE MIND) ---
|
||||
public static Vector3 globalLastKnownPosition;
|
||||
public static float globalLastSpottedTime = -999f;
|
||||
|
||||
[Header("Navigation (NavMesh)")]
|
||||
[Tooltip("How often the AI recalculates its path around buildings.")]
|
||||
public float pathRecalculationRate = 0.5f;
|
||||
@@ -46,17 +53,41 @@ public class AIController : MonoBehaviour
|
||||
private float stuckTimer = 0f;
|
||||
private float unstuckReverseTimer = 0f;
|
||||
|
||||
[Header("Arrest System")]
|
||||
[Tooltip("How close the AI must be to pin the player.")]
|
||||
public float arrestDistance = 6.5f;
|
||||
[Tooltip("If player is moving slower than this, they are considered pinned.")]
|
||||
public float playerPinSpeedThreshold = 2f;
|
||||
[Tooltip("How many seconds the player must be pinned before they are busted.")]
|
||||
public float timeToArrest = 5f;
|
||||
private float currentArrestTime = 0f;
|
||||
|
||||
[Header("Patrol Settings")]
|
||||
private Vector3 currentPatrolPoint;
|
||||
private bool hasPatrolPoint = false;
|
||||
|
||||
[Header("Rubber-Banding (Catch-Up)")]
|
||||
private float baseMaxSpeed;
|
||||
private float baseAcceleration;
|
||||
private int aiID;
|
||||
|
||||
void Start()
|
||||
{
|
||||
aiID = Random.Range(1, 100000);
|
||||
aiVehicle = GetComponent<ArcadeVehicleController>();
|
||||
if (aiVehicle != null)
|
||||
{
|
||||
baseMaxSpeed = aiVehicle.maxSpeed;
|
||||
baseAcceleration = aiVehicle.acceleration;
|
||||
}
|
||||
|
||||
currentTactic = (aiID % 3 == 0) ? AITactic.Intercept : AITactic.Chase;
|
||||
tacticTimer = Random.Range(0f, 5f);
|
||||
currentPath = new NavMeshPath();
|
||||
|
||||
if (player == null)
|
||||
{
|
||||
ArcadeVehicleController[] cars = FindObjectsByType<ArcadeVehicleController>(FindObjectsSortMode.None);
|
||||
ArcadeVehicleController[] cars = FindObjectsByType<ArcadeVehicleController>(FindObjectsInactive.Exclude);
|
||||
foreach (ArcadeVehicleController car in cars)
|
||||
{
|
||||
if (car.CompareTag("Player"))
|
||||
@@ -78,6 +109,13 @@ public class AIController : MonoBehaviour
|
||||
{
|
||||
if (player == null || aiVehicle == null || aiVehicle.carBody == null) return;
|
||||
|
||||
// Pause AI during intro/outro
|
||||
if (GameManager.Instance != null && !GameManager.Instance.isGameActive)
|
||||
{
|
||||
aiVehicle.ProvideInputs(0f, 0f, 1f); // Slam the brakes
|
||||
return;
|
||||
}
|
||||
|
||||
// If player is destroyed, ignore them and just patrol
|
||||
if (playerHealth != null && playerHealth.isDestroyed)
|
||||
{
|
||||
@@ -101,6 +139,35 @@ public class AIController : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ARREST / PINNING SYSTEM ---
|
||||
if (currentState == AIState.Pursue)
|
||||
{
|
||||
float playerSpeed = player.carBody != null ? player.carBody.linearVelocity.magnitude : player.carVelocity.magnitude;
|
||||
float distToPlayer = Vector3.Distance(aiPos, playerPos);
|
||||
|
||||
// If the AI is practically touching the player, and the player is barely moving
|
||||
if (distToPlayer < arrestDistance && playerSpeed < playerPinSpeedThreshold)
|
||||
{
|
||||
currentArrestTime += Time.fixedDeltaTime;
|
||||
|
||||
if (currentArrestTime >= timeToArrest)
|
||||
{
|
||||
if (GameManager.Instance != null && !GameManager.Instance.isGameOver)
|
||||
{
|
||||
GameManager.Instance.Busted();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
currentArrestTime = 0f; // Reset if player escapes or speeds up
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
currentArrestTime = 0f;
|
||||
}
|
||||
|
||||
if (currentSpeed < 1.0f && currentState != AIState.Patrol)
|
||||
{
|
||||
stuckTimer += Time.fixedDeltaTime;
|
||||
@@ -116,7 +183,7 @@ public class AIController : MonoBehaviour
|
||||
stuckTimer = 0f;
|
||||
}
|
||||
|
||||
// --- 2. VISION & STATE MACHINE ---
|
||||
// --- 2. VISION & STATE MACHINE (Radio Integrated) ---
|
||||
bool hasLoS = CheckLineOfSight(aiTransform, playerTransform);
|
||||
|
||||
if (hasLoS)
|
||||
@@ -124,12 +191,31 @@ public class AIController : MonoBehaviour
|
||||
currentState = AIState.Pursue;
|
||||
sightLostTimer = 0f;
|
||||
lastKnownPosition = playerPos;
|
||||
|
||||
// Broadcast to all other units over the radio!
|
||||
globalLastKnownPosition = playerPos;
|
||||
globalLastSpottedTime = Time.time;
|
||||
}
|
||||
else if (Time.time - globalLastSpottedTime < 2.0f)
|
||||
{
|
||||
// We don't have direct Line of Sight, but ANOTHER cop does and is calling it in over the radio!
|
||||
if (currentState != AIState.Pursue)
|
||||
{
|
||||
currentState = AIState.Pursue; // Jump back into the chase!
|
||||
}
|
||||
sightLostTimer = 0f;
|
||||
lastKnownPosition = globalLastKnownPosition;
|
||||
// The pursuit logic will automatically use NavMesh to drive to this radioed position (or intercept it)
|
||||
}
|
||||
else if (currentState == AIState.Pursue)
|
||||
{
|
||||
// Grace period before completely losing the player (e.g. driving behind a single tree)
|
||||
// No one has seen the player recently. Start the grace period.
|
||||
sightLostTimer += Time.fixedDeltaTime;
|
||||
if (sightLostTimer >= timeToLoseTarget)
|
||||
|
||||
// Interceptors intentionally break line of sight to take side streets, so they get a massive memory buffer.
|
||||
float allowedLostTime = (currentTactic == AITactic.Intercept) ? 15f : timeToLoseTarget;
|
||||
|
||||
if (sightLostTimer >= allowedLostTime)
|
||||
{
|
||||
currentState = AIState.Searching;
|
||||
searchTimer = 0f;
|
||||
@@ -222,8 +308,49 @@ public class AIController : MonoBehaviour
|
||||
Vector3 playerVelocity = player.carBody != null ? player.carBody.linearVelocity : Vector3.zero;
|
||||
float distance = Vector3.Distance(aiPos, playerPos);
|
||||
|
||||
// --- 0. TACTIC EVALUATION ---
|
||||
tacticTimer += Time.fixedDeltaTime;
|
||||
if (tacticTimer > 8f)
|
||||
{
|
||||
tacticTimer = 0f;
|
||||
// 30% chance to become an Interceptor if not too close, else revert to Chase
|
||||
if (distance > 25f && Random.value < 0.3f)
|
||||
{
|
||||
currentTactic = AITactic.Intercept;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentTactic = AITactic.Chase;
|
||||
}
|
||||
|
||||
// Safety fallback: if an interceptor gets incredibly far away (e.g. took a wrong turn), force chase
|
||||
if (currentTactic == AITactic.Intercept && distance > 70f)
|
||||
{
|
||||
currentTactic = AITactic.Chase;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 1. DYNAMIC RUBBER-BANDING (The "Always a Threat" System) ---
|
||||
if (distance > 45f)
|
||||
{
|
||||
float multiplier = Mathf.Clamp(distance / 45f, 1f, 2.2f);
|
||||
aiVehicle.maxSpeed = baseMaxSpeed * multiplier;
|
||||
aiVehicle.acceleration = baseAcceleration * (multiplier * 1.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
aiVehicle.maxSpeed = baseMaxSpeed;
|
||||
aiVehicle.acceleration = baseAcceleration;
|
||||
}
|
||||
|
||||
// If close and have line of sight, bypass NavMesh and attack directly!
|
||||
bool isCloseAndVisible = distance < 30f && CheckLineOfSight(aiTransform, player.carBody != null ? player.carBody.transform : player.transform);
|
||||
bool isCloseAndVisible = distance < 40f && CheckLineOfSight(aiTransform, player.carBody != null ? player.carBody.transform : player.transform);
|
||||
|
||||
// Interceptors rely on NavMesh to cut ahead unless they are practically on top of the player
|
||||
if (currentTactic == AITactic.Intercept && distance > 20f)
|
||||
{
|
||||
isCloseAndVisible = false;
|
||||
}
|
||||
|
||||
if (isCloseAndVisible)
|
||||
{
|
||||
@@ -231,26 +358,41 @@ public class AIController : MonoBehaviour
|
||||
Transform playerTransform = player.carBody != null ? player.carBody.transform : player.transform;
|
||||
Vector3 localAIPos = playerTransform.InverseTransformPoint(aiPos);
|
||||
|
||||
// PIT Maneuver Check
|
||||
if (localAIPos.z < 0f && localAIPos.z > -6f && Mathf.Abs(localAIPos.x) > 1.2f && distance < 12f)
|
||||
// --- 2. AGGRESSIVE FLANKING & PINNING ---
|
||||
if (localAIPos.z > 0f && distance < 15f)
|
||||
{
|
||||
Transform targetWheel = player.rearWheels[0];
|
||||
if (player.rearWheels.Length > 1 && Vector3.Distance(aiPos, player.rearWheels[1].position) < Vector3.Distance(aiPos, targetWheel.position))
|
||||
// We are IN FRONT of the player. BRAKE CHECK!
|
||||
// Target is placed directly behind us (at player's position) so angle is 180, causing AI to slam brakes
|
||||
targetPoint = playerPos;
|
||||
}
|
||||
else if (localAIPos.z > -8f && Mathf.Abs(localAIPos.x) > 1.5f && distance < 15f)
|
||||
{
|
||||
// We are ALONGSIDE the player. T-BONE / SIDESWIPE (The NFS UC Box-In)
|
||||
if (localAIPos.x < 0)
|
||||
{
|
||||
targetWheel = player.rearWheels[1];
|
||||
// AI is on the left, violently ram the player's front-left quarter panel
|
||||
targetPoint = playerPos + playerTransform.forward * 2f + playerTransform.right * 1f;
|
||||
}
|
||||
|
||||
Vector3 pushDirection = (playerPos - targetWheel.position).normalized;
|
||||
targetPoint = targetWheel.position + (playerTransform.forward * 0.5f) + (pushDirection * 0.5f);
|
||||
else
|
||||
{
|
||||
// AI is on the right, violently ram the player's front-right quarter panel
|
||||
targetPoint = playerPos + playerTransform.forward * 2f - playerTransform.right * 1f;
|
||||
}
|
||||
}
|
||||
else if (localAIPos.z < -2f && localAIPos.z > -12f && Mathf.Abs(localAIPos.x) <= 3f && distance < 15f)
|
||||
{
|
||||
// We are DIRECTLY BEHIND the player. PIT MANEUVER.
|
||||
float pitOffset = (aiID % 2 == 0) ? -2.0f : 2.0f;
|
||||
targetPoint = playerPos - playerTransform.forward * 1.5f + playerTransform.right * pitOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ramming Check
|
||||
float leadTime = Mathf.Clamp(distance / Mathf.Max(currentSpeed, minRamSpeed), 0f, 1.0f);
|
||||
// General Ramming / Catching up
|
||||
float leadTime = Mathf.Clamp(distance / Mathf.Max(currentSpeed, minRamSpeed), 0f, 1.5f);
|
||||
targetPoint = playerPos + (playerVelocity * leadTime);
|
||||
|
||||
// Offset the target so they don't all aim for the exact same pixel (Flocking/V-Formation)
|
||||
float sideOffset = (GetInstanceID() % 3 == 0) ? 0f : (GetInstanceID() % 2 == 0 ? -2.5f : 2.5f);
|
||||
// Flocking Offset so they don't drive in a single file line
|
||||
float sideOffset = (aiID % 3 == 0) ? 0f : (aiID % 2 == 0 ? -3.0f : 3.0f);
|
||||
targetPoint += playerTransform.right * sideOffset;
|
||||
}
|
||||
|
||||
@@ -258,8 +400,11 @@ public class AIController : MonoBehaviour
|
||||
}
|
||||
else
|
||||
{
|
||||
// Distant pursuit: Use NavMesh to navigate city streets without hitting buildings
|
||||
Vector3 waypoint = GetNavMeshWaypoint(aiPos, playerPos);
|
||||
// --- 3. PREDICTIVE INTERCEPT PATHING (No More Conga Lines) ---
|
||||
// Interceptors predict 4.5s ahead (taking side streets to cut off). Chasers predict 2.5s ahead.
|
||||
float predictionTime = (currentTactic == AITactic.Intercept) ? 4.5f : 2.5f;
|
||||
Vector3 predictedPos = playerPos + (playerVelocity * predictionTime);
|
||||
Vector3 waypoint = GetNavMeshWaypoint(aiPos, predictedPos);
|
||||
DriveTowardsPoint(aiTransform, aiPos, waypoint, currentSpeed, true);
|
||||
|
||||
// Draw path for debugging
|
||||
@@ -348,18 +493,32 @@ public class AIController : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 1. ANTI-CRASH AVOIDANCE (Avoid other cops!) ---
|
||||
// --- 1. ANTI-CRASH AVOIDANCE & LANE OFFSET ---
|
||||
Vector3 avoidanceOffset = Vector3.zero;
|
||||
bool needsToBrakeForAlly = false;
|
||||
float avoidDist = aggressive ? 12f : 20f;
|
||||
float avoidDist = aggressive ? 15f : 20f;
|
||||
|
||||
// Apply a persistent lane offset so they naturally spread out across the road instead of forming a conga line
|
||||
if (aggressive)
|
||||
{
|
||||
float laneOffset = (aiID % 3 == 0) ? 0f : (aiID % 2 == 0 ? -1.2f : 1.2f);
|
||||
avoidanceOffset += aiTransform.right * laneOffset;
|
||||
}
|
||||
|
||||
// Check if player is assigned before accessing
|
||||
Transform playerT = player != null && player.carBody != null ? player.carBody.transform : (player != null ? player.transform : null);
|
||||
|
||||
// Ray Center
|
||||
if (Physics.Raycast(aiPos + Vector3.up, aiTransform.forward, out RaycastHit hitC, avoidDist))
|
||||
{
|
||||
if (hitC.collider.GetComponentInParent<AIController>() != null)
|
||||
{
|
||||
needsToBrakeForAlly = true;
|
||||
avoidanceOffset += aiTransform.right * (Random.value > 0.5f ? 1.5f : -1.5f);
|
||||
avoidanceOffset += aiTransform.right * (Random.value > 0.5f ? 2.5f : -2.5f);
|
||||
}
|
||||
else if (playerT != null && !hitC.collider.CompareTag("Player") && !hitC.collider.transform.IsChildOf(playerT))
|
||||
{
|
||||
avoidanceOffset += hitC.normal * 2.5f; // Steer away from wall ahead
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +527,12 @@ public class AIController : MonoBehaviour
|
||||
{
|
||||
if (hitL.collider.GetComponentInParent<AIController>() != null)
|
||||
{
|
||||
avoidanceOffset += aiTransform.right * 1.5f;
|
||||
if (hitL.distance < 8f) needsToBrakeForAlly = true;
|
||||
avoidanceOffset += aiTransform.right * 2.5f;
|
||||
if (hitL.distance < 10f) needsToBrakeForAlly = true;
|
||||
}
|
||||
else if (playerT != null && !hitL.collider.CompareTag("Player") && !hitL.collider.transform.IsChildOf(playerT))
|
||||
{
|
||||
avoidanceOffset += aiTransform.right * 2.5f; // Wall on left, steer right
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,8 +541,12 @@ public class AIController : MonoBehaviour
|
||||
{
|
||||
if (hitR.collider.GetComponentInParent<AIController>() != null)
|
||||
{
|
||||
avoidanceOffset -= aiTransform.right * 1.5f;
|
||||
if (hitR.distance < 8f) needsToBrakeForAlly = true;
|
||||
avoidanceOffset -= aiTransform.right * 2.5f;
|
||||
if (hitR.distance < 10f) needsToBrakeForAlly = true;
|
||||
}
|
||||
else if (playerT != null && !hitR.collider.CompareTag("Player") && !hitR.collider.transform.IsChildOf(playerT))
|
||||
{
|
||||
avoidanceOffset -= aiTransform.right * 2.5f; // Wall on right, steer left
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,13 +597,16 @@ public class AIController : MonoBehaviour
|
||||
|
||||
aiVehicle.ProvideInputs(steerInput, throttleInput, brakeInput);
|
||||
|
||||
// --- 4. FORCED ROTATION ---
|
||||
// --- 4. FORCED ROTATION (Overdrive Steering) ---
|
||||
// Don't force rotation if we are actively swerving to avoid a buddy
|
||||
if (!needsToBrakeForAlly && (aggressive || currentSpeed < 2f) && Mathf.Abs(angleToTarget) > 2f)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(dirToTarget, Vector3.up);
|
||||
Vector3 currentEuler = aiTransform.rotation.eulerAngles;
|
||||
float newY = Mathf.LerpAngle(currentEuler.y, targetRotation.eulerAngles.y, forcedTurnSpeed * Time.fixedDeltaTime);
|
||||
|
||||
// Cheat: If aggressive (ramming), snap the steering much faster than natural physics allows
|
||||
float actualTurnSpeed = aggressive ? forcedTurnSpeed * 1.8f : forcedTurnSpeed;
|
||||
float newY = Mathf.LerpAngle(currentEuler.y, targetRotation.eulerAngles.y, actualTurnSpeed * Time.fixedDeltaTime);
|
||||
|
||||
aiVehicle.carBody.MoveRotation(Quaternion.Euler(currentEuler.x, newY, currentEuler.z));
|
||||
}
|
||||
|
||||
@@ -138,9 +138,20 @@ public class CarHealth : MonoBehaviour
|
||||
|
||||
Debug.Log($"<b>{gameObject.name}</b> took <color=orange>{amount:F1}</color> damage! Health: {currentHealth:F1}");
|
||||
|
||||
// --- GAME MANAGER HOOKS FOR PLAYER ---
|
||||
if (gameObject.CompareTag("Player") && GameManager.Instance != null)
|
||||
{
|
||||
GameManager.Instance.OnPlayerHit(amount);
|
||||
}
|
||||
|
||||
if (currentHealth <= 0f)
|
||||
{
|
||||
DestroyCar();
|
||||
|
||||
if (gameObject.CompareTag("Player") && GameManager.Instance != null)
|
||||
{
|
||||
GameManager.Instance.GameOver();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
267
Assets/Scripts/GameManager.cs
Normal file
267
Assets/Scripts/GameManager.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using TMPro; // TextMeshPro
|
||||
using UnityEngine.SceneManagement;
|
||||
using ArcadeVP;
|
||||
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
public static GameManager Instance { get; private set; }
|
||||
|
||||
[Header("Level Settings")]
|
||||
public string locationName = "the Downtown Bank";
|
||||
public float startingStolenMoney = 50000f;
|
||||
[Tooltip("How much money is lost for every 1 point of damage the player takes.")]
|
||||
public float moneyLostPerHitMultiplier = 100f;
|
||||
|
||||
[Header("Text Templates")]
|
||||
[TextArea]
|
||||
public string introTextTemplate = "You have robbed {0} and stole ${1:N0}.\nReach your safe point.";
|
||||
[TextArea]
|
||||
public string successTextTemplate = "You successfully escaped {0}!\nYou safely secured ${1:N0}!\nTotal Cash Pile: ${2:N0}";
|
||||
[TextArea]
|
||||
public string raidedTextTemplate = "SAFE HOUSE RAIDED!\nYou led the cops right to your front door after robbing {0}.\nAll previously collected money has been seized by the police.";
|
||||
[TextArea]
|
||||
public string bustedTextTemplate = "BUSTED!\nYou were pinned by the police and arrested.\nAll previously collected money has been seized.";
|
||||
|
||||
[Header("Current State (Read Only)")]
|
||||
public float currentStolenMoney;
|
||||
public float totalCashPile;
|
||||
public bool isGameOver = false;
|
||||
public bool isGameActive = false; // Controls player and AI input
|
||||
|
||||
[Header("UI References")]
|
||||
[Tooltip("A UI Panel with an Image component set to Black to fade in from.")]
|
||||
public CanvasGroup fadePanel;
|
||||
[Tooltip("The text element showing the intro text.")]
|
||||
public TextMeshProUGUI introText;
|
||||
|
||||
[Space(10)]
|
||||
public TextMeshProUGUI moneyDisplay;
|
||||
|
||||
[Space(10)]
|
||||
public GameObject gameOverPanel;
|
||||
|
||||
[Space(10)]
|
||||
public GameObject levelCompletePanel;
|
||||
public TextMeshProUGUI levelCompleteTitle;
|
||||
public TextMeshProUGUI levelCompleteDetails;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Load persistent cash pile from previous levels
|
||||
totalCashPile = PlayerPrefs.GetFloat("TotalCashPile", 0f);
|
||||
currentStolenMoney = startingStolenMoney;
|
||||
isGameOver = false;
|
||||
isGameActive = false; // Disable controls during intro
|
||||
|
||||
// Initialize UI panels
|
||||
if (gameOverPanel) gameOverPanel.SetActive(false);
|
||||
if (levelCompletePanel) levelCompletePanel.SetActive(false);
|
||||
UpdateMoneyDisplay();
|
||||
|
||||
StartCoroutine(IntroSequence());
|
||||
}
|
||||
|
||||
private IEnumerator IntroSequence()
|
||||
{
|
||||
if (fadePanel == null || introText == null) yield break;
|
||||
|
||||
// Set screen to black
|
||||
fadePanel.alpha = 1f;
|
||||
fadePanel.gameObject.SetActive(true);
|
||||
introText.gameObject.SetActive(true);
|
||||
|
||||
// Setup intro text using the template
|
||||
introText.text = string.Format(introTextTemplate, locationName, startingStolenMoney);
|
||||
|
||||
// Set text alpha to 0 manually
|
||||
Color c = introText.color;
|
||||
c.a = 0f;
|
||||
introText.color = c;
|
||||
|
||||
// Fade out black screen
|
||||
float t = 0f;
|
||||
while (t < 1f)
|
||||
{
|
||||
t += Time.deltaTime * 0.5f;
|
||||
fadePanel.alpha = Mathf.Lerp(1f, 0f, t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
fadePanel.blocksRaycasts = false;
|
||||
|
||||
// Fade IN text
|
||||
t = 0f;
|
||||
while (t < 1f)
|
||||
{
|
||||
t += Time.deltaTime * 0.8f;
|
||||
c.a = Mathf.Lerp(0f, 1f, t);
|
||||
introText.color = c;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(4f);
|
||||
|
||||
// Fade OUT text
|
||||
t = 0f;
|
||||
while (t < 1f)
|
||||
{
|
||||
t += Time.deltaTime * 0.8f;
|
||||
c.a = Mathf.Lerp(1f, 0f, t);
|
||||
introText.color = c;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
introText.gameObject.SetActive(false);
|
||||
isGameActive = true; // Give control back to player and AI
|
||||
}
|
||||
|
||||
public void OnPlayerHit(float damageTaken)
|
||||
{
|
||||
if (isGameOver || !isGameActive) return;
|
||||
|
||||
float moneyToLose = damageTaken * moneyLostPerHitMultiplier;
|
||||
currentStolenMoney -= moneyToLose;
|
||||
|
||||
if (currentStolenMoney < 0) currentStolenMoney = 0;
|
||||
|
||||
UpdateMoneyDisplay();
|
||||
}
|
||||
|
||||
public void GameOver()
|
||||
{
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
isGameActive = false;
|
||||
|
||||
if (gameOverPanel != null)
|
||||
{
|
||||
gameOverPanel.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Busted()
|
||||
{
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
isGameActive = false;
|
||||
|
||||
// Lose all money
|
||||
totalCashPile = 0f;
|
||||
PlayerPrefs.SetFloat("TotalCashPile", 0f);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Show the level complete panel but with the Busted text
|
||||
if (levelCompletePanel != null)
|
||||
{
|
||||
levelCompletePanel.SetActive(true);
|
||||
}
|
||||
|
||||
if (levelCompleteTitle)
|
||||
{
|
||||
levelCompleteTitle.text = "BUSTED!";
|
||||
levelCompleteTitle.color = Color.red;
|
||||
}
|
||||
if (levelCompleteDetails)
|
||||
{
|
||||
levelCompleteDetails.text = bustedTextTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
public void EnterSafeHouse()
|
||||
{
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
isGameActive = false;
|
||||
|
||||
// Determine if cops are actively chasing (Ignore searching if player is hidden)
|
||||
bool isBeingChased = false;
|
||||
|
||||
AIController[] allAI = FindObjectsByType<AIController>(FindObjectsInactive.Exclude);
|
||||
foreach (AIController ai in allAI)
|
||||
{
|
||||
if (ai.currentState == AIController.AIState.Pursue)
|
||||
{
|
||||
isBeingChased = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Show End Screen
|
||||
if (levelCompletePanel != null)
|
||||
{
|
||||
levelCompletePanel.SetActive(true);
|
||||
}
|
||||
|
||||
if (isBeingChased)
|
||||
{
|
||||
// RAIDED - Lost all collected money
|
||||
totalCashPile = 0f;
|
||||
PlayerPrefs.SetFloat("TotalCashPile", 0f); // Wipe the save file
|
||||
|
||||
if (levelCompleteTitle)
|
||||
{
|
||||
levelCompleteTitle.text = "BUSTED!";
|
||||
levelCompleteTitle.color = Color.red;
|
||||
}
|
||||
if (levelCompleteDetails)
|
||||
{
|
||||
levelCompleteDetails.text = string.Format(raidedTextTemplate, locationName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// SUCCESS - Keep the loot
|
||||
totalCashPile += currentStolenMoney;
|
||||
PlayerPrefs.SetFloat("TotalCashPile", totalCashPile);
|
||||
|
||||
if (levelCompleteTitle)
|
||||
{
|
||||
levelCompleteTitle.text = "ESCAPE SUCCESSFUL";
|
||||
levelCompleteTitle.color = Color.green;
|
||||
}
|
||||
if (levelCompleteDetails)
|
||||
{
|
||||
levelCompleteDetails.text = string.Format(successTextTemplate, locationName, currentStolenMoney, totalCashPile);
|
||||
}
|
||||
}
|
||||
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
private void UpdateMoneyDisplay()
|
||||
{
|
||||
if (moneyDisplay != null)
|
||||
{
|
||||
moneyDisplay.text = $"Loot: ${currentStolenMoney:N0}";
|
||||
}
|
||||
}
|
||||
|
||||
// --- BUTTON METHODS ---
|
||||
|
||||
public void RestartLevel()
|
||||
{
|
||||
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
|
||||
}
|
||||
|
||||
public void QuitGame()
|
||||
{
|
||||
Application.Quit();
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorApplication.isPlaying = false;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/GameManager.cs.meta
Normal file
2
Assets/Scripts/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 272d7e61a2c6b3f4ebfcf9567f64ecb0
|
||||
79
Assets/Scripts/GarageDoor.cs
Normal file
79
Assets/Scripts/GarageDoor.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
|
||||
public class GarageDoor : MonoBehaviour
|
||||
{
|
||||
[Header("Detection Settings")]
|
||||
[Tooltip("How close the player needs to be for the door to open.")]
|
||||
public float openDistance = 50f;
|
||||
|
||||
[Header("Movement Settings")]
|
||||
[Tooltip("The physical door object that moves.")]
|
||||
public Transform doorMesh;
|
||||
[Tooltip("How far and in what direction the door slides when open (e.g., Y = 4.5 to slide up).")]
|
||||
public Vector3 openOffset = new Vector3(0f, 4.5f, 0f);
|
||||
public float moveSpeed = 3f;
|
||||
|
||||
private Transform player;
|
||||
private Vector3 closedLocalPos;
|
||||
private Vector3 openLocalPos;
|
||||
|
||||
private bool isOpen = false;
|
||||
private bool isClosedPermanently = false;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// If they didn't assign a child mesh, assume this object itself is the door
|
||||
if (doorMesh == null) doorMesh = this.transform;
|
||||
|
||||
closedLocalPos = doorMesh.localPosition;
|
||||
openLocalPos = closedLocalPos + openOffset;
|
||||
|
||||
// Automatically find the player
|
||||
GameObject p = GameObject.FindGameObjectWithTag("Player");
|
||||
if (p != null) player = p.transform;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Don't open again if the level has ended!
|
||||
if (isClosedPermanently || player == null) return;
|
||||
|
||||
// Check distance to player
|
||||
float distance = Vector3.Distance(transform.position, player.position);
|
||||
|
||||
if (distance <= openDistance && !isOpen)
|
||||
{
|
||||
OpenDoor();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenDoor()
|
||||
{
|
||||
isOpen = true;
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(AnimateDoor(openLocalPos));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the SafeHouseTrigger when the player parks inside.
|
||||
/// </summary>
|
||||
public void CloseDoor()
|
||||
{
|
||||
isClosedPermanently = true; // Locks the door so it doesn't open again
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(AnimateDoor(closedLocalPos));
|
||||
}
|
||||
|
||||
private IEnumerator AnimateDoor(Vector3 targetLocalPos)
|
||||
{
|
||||
while (Vector3.Distance(doorMesh.localPosition, targetLocalPos) > 0.01f)
|
||||
{
|
||||
// Smoothly slide the door to the target position
|
||||
doorMesh.localPosition = Vector3.Lerp(doorMesh.localPosition, targetLocalPos, Time.deltaTime * moveSpeed);
|
||||
yield return null;
|
||||
}
|
||||
doorMesh.localPosition = targetLocalPos;
|
||||
}
|
||||
}
|
||||
|
||||
2
Assets/Scripts/GarageDoor.cs.meta
Normal file
2
Assets/Scripts/GarageDoor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a5be801f965c09448a77df5eb8f9022
|
||||
176
Assets/Scripts/NPCController.cs
Normal file
176
Assets/Scripts/NPCController.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
|
||||
[RequireComponent(typeof(NavMeshAgent), typeof(Animator))]
|
||||
public class NPCController : MonoBehaviour
|
||||
{
|
||||
public enum NPCState { Walking, Drinking, Golfing, Ragdolled }
|
||||
|
||||
[Header("Current State")]
|
||||
public NPCState currentState = NPCState.Walking;
|
||||
|
||||
[Header("Wander Settings")]
|
||||
public float wanderRadius = 15f;
|
||||
[Tooltip("How long they stand still to drink or play golf.")]
|
||||
public float minActionTime = 3f;
|
||||
public float maxActionTime = 7f;
|
||||
|
||||
[Header("Impact & Physics")]
|
||||
[Tooltip("How violently they get thrown when hit by a car.")]
|
||||
public float hitForceMultiplier = 1.2f;
|
||||
|
||||
private NavMeshAgent agent;
|
||||
private Animator anim;
|
||||
|
||||
private Rigidbody[] ragdollBodies;
|
||||
private Collider[] ragdollColliders;
|
||||
private Collider mainCollider;
|
||||
|
||||
void Start()
|
||||
{
|
||||
agent = GetComponent<NavMeshAgent>();
|
||||
anim = GetComponent<Animator>();
|
||||
mainCollider = GetComponent<Collider>();
|
||||
|
||||
// Find all ragdoll bones generated by Unity's Ragdoll Wizard
|
||||
ragdollBodies = GetComponentsInChildren<Rigidbody>();
|
||||
ragdollColliders = GetComponentsInChildren<Collider>();
|
||||
|
||||
// Disable ragdoll physics on spawn so they can walk normally
|
||||
foreach (Rigidbody rb in ragdollBodies)
|
||||
{
|
||||
// Skip the root if it has a rigidbody
|
||||
if (rb.gameObject == this.gameObject) continue;
|
||||
rb.isKinematic = true;
|
||||
}
|
||||
foreach (Collider col in ragdollColliders)
|
||||
{
|
||||
if (col.gameObject == this.gameObject) continue;
|
||||
col.enabled = false;
|
||||
}
|
||||
|
||||
StartCoroutine(BehaviorRoutine());
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (currentState == NPCState.Ragdolled) return;
|
||||
|
||||
// Pass the movement speed to the Animator (Make sure you have a "Speed" float parameter in your Animator!)
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetFloat("Speed", agent.velocity.magnitude);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator BehaviorRoutine()
|
||||
{
|
||||
while (currentState != NPCState.Ragdolled)
|
||||
{
|
||||
if (currentState == NPCState.Walking)
|
||||
{
|
||||
// Pick a random spot on the golf course
|
||||
Vector3 randomDir = Random.insideUnitSphere * wanderRadius;
|
||||
randomDir += transform.position;
|
||||
|
||||
if (NavMesh.SamplePosition(randomDir, out NavMeshHit hit, wanderRadius, NavMesh.AllAreas))
|
||||
{
|
||||
agent.SetDestination(hit.position);
|
||||
}
|
||||
|
||||
// Wait until they reach the destination
|
||||
while (agent.pathPending || agent.remainingDistance > 0.5f)
|
||||
{
|
||||
if (currentState == NPCState.Ragdolled) yield break;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Reached destination! Pick a random action
|
||||
int action = Random.Range(0, 3);
|
||||
if (action == 0) currentState = NPCState.Walking; // Just keep walking
|
||||
else if (action == 1) currentState = NPCState.Drinking;
|
||||
else currentState = NPCState.Golfing;
|
||||
}
|
||||
else if (currentState == NPCState.Drinking)
|
||||
{
|
||||
// Play drink animation
|
||||
if (anim != null) anim.SetTrigger("Drink");
|
||||
|
||||
yield return new WaitForSeconds(Random.Range(minActionTime, maxActionTime));
|
||||
currentState = NPCState.Walking;
|
||||
}
|
||||
else if (currentState == NPCState.Golfing)
|
||||
{
|
||||
// Play golf swing animation
|
||||
if (anim != null) anim.SetTrigger("GolfSwing");
|
||||
|
||||
yield return new WaitForSeconds(Random.Range(minActionTime, maxActionTime));
|
||||
currentState = NPCState.Walking;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if a car crashes into them
|
||||
private void OnCollisionEnter(Collision collision)
|
||||
{
|
||||
if (currentState == NPCState.Ragdolled) return;
|
||||
|
||||
// If hit with enough speed (e.g. a car)
|
||||
if (collision.relativeVelocity.magnitude > 4f)
|
||||
{
|
||||
Vector3 impactPoint = collision.GetContact(0).point;
|
||||
EnableRagdoll(collision.relativeVelocity, impactPoint);
|
||||
}
|
||||
}
|
||||
|
||||
// Also support triggers just in case your car uses trigger hitboxes
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (currentState == NPCState.Ragdolled) return;
|
||||
|
||||
Rigidbody otherRb = other.GetComponentInParent<Rigidbody>();
|
||||
if (otherRb != null && otherRb.linearVelocity.magnitude > 4f)
|
||||
{
|
||||
EnableRagdoll(otherRb.linearVelocity, transform.position);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableRagdoll(Vector3 impactVelocity, Vector3 impactPoint)
|
||||
{
|
||||
currentState = NPCState.Ragdolled;
|
||||
|
||||
// Turn off AI and Animations
|
||||
if (agent != null) agent.enabled = false;
|
||||
if (anim != null) anim.enabled = false;
|
||||
if (mainCollider != null) mainCollider.enabled = false;
|
||||
|
||||
StopAllCoroutines();
|
||||
|
||||
// Calculate how hard they get thrown
|
||||
// Add a little upward force so they fly through the air!
|
||||
Vector3 force = (impactVelocity + Vector3.up * 3f) * hitForceMultiplier;
|
||||
|
||||
// Turn ON the ragdoll colliders
|
||||
foreach (Collider col in ragdollColliders)
|
||||
{
|
||||
if (col.gameObject != this.gameObject) col.enabled = true;
|
||||
}
|
||||
|
||||
// Turn ON the ragdoll physics and apply the crash force
|
||||
foreach (Rigidbody rb in ragdollBodies)
|
||||
{
|
||||
if (rb.gameObject != this.gameObject)
|
||||
{
|
||||
rb.isKinematic = false;
|
||||
rb.useGravity = true;
|
||||
|
||||
// VelocityChange ignores the mass of the bone, guaranteeing they fly away violently
|
||||
rb.AddForceAtPosition(force, impactPoint, ForceMode.VelocityChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
Assets/Scripts/NPCController.cs.meta
Normal file
2
Assets/Scripts/NPCController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0cffc9995b098a24ea2e28c53cd4b3f1
|
||||
58
Assets/Scripts/SafeHouseTrigger.cs
Normal file
58
Assets/Scripts/SafeHouseTrigger.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
using ArcadeVP;
|
||||
|
||||
public class SafeHouseTrigger : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Optional: Drag your GarageDoor script here so it shuts behind you when you park.")]
|
||||
public GarageDoor garageDoor;
|
||||
|
||||
private bool triggered = false;
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
// Ignore if the game is already over or we already triggered this
|
||||
if (triggered || GameManager.Instance == null || GameManager.Instance.isGameOver) return;
|
||||
|
||||
ArcadeVehicleController vehicle = null;
|
||||
|
||||
// Check if the object entering the trigger is the Player directly
|
||||
if (other.CompareTag("Player"))
|
||||
{
|
||||
vehicle = other.GetComponent<ArcadeVehicleController>();
|
||||
if (vehicle == null) vehicle = other.GetComponentInParent<ArcadeVehicleController>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Also check if a child collider of the player entered
|
||||
vehicle = other.GetComponentInParent<ArcadeVehicleController>();
|
||||
}
|
||||
|
||||
if (vehicle != null && vehicle.CompareTag("Player"))
|
||||
{
|
||||
triggered = true;
|
||||
StartCoroutine(WaitToStopAndClose(vehicle));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator WaitToStopAndClose(ArcadeVehicleController vehicle)
|
||||
{
|
||||
// Wait until the car has essentially stopped moving (speed < 1 m/s)
|
||||
while (vehicle.carBody != null && vehicle.carBody.linearVelocity.magnitude > 1f)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Shut the garage door!
|
||||
if (garageDoor != null)
|
||||
{
|
||||
garageDoor.CloseDoor();
|
||||
|
||||
// Wait 2.5 seconds for the door to visually close before ending the level
|
||||
yield return new WaitForSeconds(2.5f);
|
||||
}
|
||||
|
||||
// Complete the level
|
||||
GameManager.Instance.EnterSafeHouse();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/SafeHouseTrigger.cs.meta
Normal file
2
Assets/Scripts/SafeHouseTrigger.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a2ee7997e8a08f4fa502e1313bee04c
|
||||
329
Assets/Scripts/TerrainDetector.cs
Normal file
329
Assets/Scripts/TerrainDetector.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using ArcadeVP;
|
||||
|
||||
public class TerrainDetector : MonoBehaviour
|
||||
{
|
||||
[System.Serializable]
|
||||
public class SurfaceSetting
|
||||
{
|
||||
public string name = "Surface";
|
||||
|
||||
[Tooltip("Optional: Assign the Terrain Layer asset directly (from your Terrain).")]
|
||||
public TerrainLayer terrainLayer;
|
||||
|
||||
[Tooltip("If Terrain Layer asset is not assigned, this index is used (0 = 1st painted texture, 1 = 2nd, etc.).")]
|
||||
public int textureIndex = -1;
|
||||
|
||||
[Header("Vehicle Multipliers")]
|
||||
[Tooltip("Multiplier applied to vehicle max speed (e.g. 1.0 = asphalt, 0.7 = dirt, 0.45 = mud).")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float speedMultiplier = 1f;
|
||||
|
||||
[Tooltip("Multiplier applied to vehicle acceleration.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float accelerationMultiplier = 1f;
|
||||
|
||||
[Tooltip("Rigidbody linear damping / drag (higher value = more resistance / harder to move).")]
|
||||
public float linearDamping = 0.05f;
|
||||
|
||||
[Header("Physics Material")]
|
||||
[Tooltip("Optional Physics Material to apply to vehicle friction / tires on this surface.")]
|
||||
public PhysicsMaterial physicsMaterial;
|
||||
|
||||
[Tooltip("Multiplier applied to the vehicle's dynamic friction.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float frictionMultiplier = 1f;
|
||||
}
|
||||
|
||||
[Header("Raycast Settings")]
|
||||
[Tooltip("Offset from the vehicle/player origin to start the downward ray.")]
|
||||
public Vector3 rayOriginOffset = new Vector3(0f, 1f, 0f);
|
||||
|
||||
[Tooltip("Maximum raycast distance downwards to find the terrain.")]
|
||||
public float rayDistance = 4f;
|
||||
|
||||
[Tooltip("Layers to check for ground / terrain (include Terrain and Road layers).")]
|
||||
public LayerMask groundLayers = ~0;
|
||||
|
||||
[Header("Surface Presets")]
|
||||
[Tooltip("Settings for different painted textures on your terrain.")]
|
||||
public List<SurfaceSetting> surfaceSettings = new List<SurfaceSetting>();
|
||||
|
||||
[Tooltip("Default surface setting used when driving on non-terrain surfaces (e.g. road meshes) or when airborne.")]
|
||||
public SurfaceSetting defaultSurface = new SurfaceSetting
|
||||
{
|
||||
name = "Road / Default",
|
||||
textureIndex = -1,
|
||||
speedMultiplier = 1.0f,
|
||||
accelerationMultiplier = 1.0f,
|
||||
linearDamping = 0.05f,
|
||||
frictionMultiplier = 1.0f
|
||||
};
|
||||
|
||||
[Header("Transition Smoothing")]
|
||||
[Tooltip("How quickly surface properties blend when switching terrain types.")]
|
||||
public float transitionSpeed = 5f;
|
||||
|
||||
[Header("Target References (Auto-detected if blank)")]
|
||||
public ArcadeVehicleController vehicleController;
|
||||
public Rigidbody targetRigidbody;
|
||||
|
||||
[Header("Debug Info (Read Only)")]
|
||||
[SerializeField] private string currentSurfaceName = "None";
|
||||
[SerializeField] private int currentDominantTextureIndex = -1;
|
||||
[SerializeField] private float currentTextureWeight = 0f;
|
||||
[SerializeField] private float currentSpeedMultiplier = 1f;
|
||||
|
||||
// Cached base vehicle values
|
||||
private float baseMaxSpeed = 50f;
|
||||
private float baseAcceleration = 10f;
|
||||
private float baseLinearDamping = 0.05f;
|
||||
private float baseDynamicFriction = 1f;
|
||||
|
||||
// Current blended values
|
||||
private float targetMaxSpeed;
|
||||
private float targetAcceleration;
|
||||
private float targetLinearDamping;
|
||||
private float targetDynamicFriction;
|
||||
|
||||
private Terrain lastDetectedTerrain;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Auto-find vehicle controller if not assigned
|
||||
if (vehicleController == null)
|
||||
{
|
||||
vehicleController = GetComponent<ArcadeVehicleController>();
|
||||
if (vehicleController == null)
|
||||
{
|
||||
vehicleController = GetComponentInParent<ArcadeVehicleController>();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-find Rigidbody if not assigned
|
||||
if (targetRigidbody == null)
|
||||
{
|
||||
if (vehicleController != null && vehicleController.rb != null)
|
||||
{
|
||||
targetRigidbody = vehicleController.rb;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetRigidbody = GetComponent<Rigidbody>();
|
||||
if (targetRigidbody == null)
|
||||
{
|
||||
targetRigidbody = GetComponentInParent<Rigidbody>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache base values
|
||||
if (vehicleController != null)
|
||||
{
|
||||
baseMaxSpeed = vehicleController.maxSpeed;
|
||||
baseAcceleration = vehicleController.acceleration;
|
||||
if (vehicleController.frictionMaterial != null)
|
||||
{
|
||||
baseDynamicFriction = vehicleController.frictionMaterial.dynamicFriction;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRigidbody != null)
|
||||
{
|
||||
baseLinearDamping = targetRigidbody.linearDamping;
|
||||
}
|
||||
|
||||
// Initialize targets to default
|
||||
targetMaxSpeed = baseMaxSpeed;
|
||||
targetAcceleration = baseAcceleration;
|
||||
targetLinearDamping = baseLinearDamping;
|
||||
targetDynamicFriction = baseDynamicFriction;
|
||||
}
|
||||
|
||||
void FixedUpdate()
|
||||
{
|
||||
DetectSurface();
|
||||
ApplySurfacePhysics();
|
||||
}
|
||||
|
||||
private void DetectSurface()
|
||||
{
|
||||
Vector3 rayStart = transform.position + rayOriginOffset;
|
||||
Ray ray = new Ray(rayStart, Vector3.down);
|
||||
|
||||
if (Physics.Raycast(ray, out RaycastHit hit, rayDistance, groundLayers))
|
||||
{
|
||||
Terrain terrain = hit.collider.GetComponent<Terrain>();
|
||||
if (terrain == null)
|
||||
{
|
||||
terrain = hit.collider.GetComponentInParent<Terrain>();
|
||||
}
|
||||
if (terrain == null)
|
||||
{
|
||||
terrain = Terrain.activeTerrain;
|
||||
}
|
||||
|
||||
if (terrain != null && terrain.terrainData != null)
|
||||
{
|
||||
lastDetectedTerrain = terrain;
|
||||
int dominantIndex = GetDominantTextureIndex(hit.point, terrain, out float[] weights);
|
||||
|
||||
if (dominantIndex >= 0)
|
||||
{
|
||||
currentDominantTextureIndex = dominantIndex;
|
||||
currentTextureWeight = (weights != null && dominantIndex < weights.Length) ? weights[dominantIndex] : 1f;
|
||||
|
||||
// Match surface setting
|
||||
SurfaceSetting matchedSetting = FindMatchingSetting(terrain, dominantIndex);
|
||||
ApplySettingTargets(matchedSetting);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Hit a ground object that is not a terrain (e.g. road mesh, bridge, ramp)
|
||||
ApplySettingTargets(defaultSurface);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Airborne
|
||||
currentSurfaceName = "Airborne";
|
||||
ApplySettingTargets(defaultSurface);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples the Terrain Alphamap (Splatmap) at the given world position.
|
||||
/// Returns the dominant texture index and an array of all texture weights at that coordinate.
|
||||
/// </summary>
|
||||
public static int GetDominantTextureIndex(Vector3 worldPos, Terrain terrain, out float[] textureWeights)
|
||||
{
|
||||
textureWeights = null;
|
||||
if (terrain == null || terrain.terrainData == null) return -1;
|
||||
|
||||
TerrainData terrainData = terrain.terrainData;
|
||||
Vector3 terrainPos = terrain.transform.position;
|
||||
|
||||
// 1. Convert world position to terrain-relative coordinates
|
||||
float relX = worldPos.x - terrainPos.x;
|
||||
float relZ = worldPos.z - terrainPos.z;
|
||||
|
||||
// 2. Convert to normalized terrain coordinates (0.0 to 1.0)
|
||||
float normX = relX / terrainData.size.x;
|
||||
float normZ = relZ / terrainData.size.z;
|
||||
|
||||
// Bounds check
|
||||
if (normX < 0f || normX > 1f || normZ < 0f || normZ > 1f)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 3. Map normalized coordinates to splatmap resolution
|
||||
int mapX = Mathf.Clamp((int)(normX * terrainData.alphamapResolution), 0, terrainData.alphamapResolution - 1);
|
||||
int mapZ = Mathf.Clamp((int)(normZ * terrainData.alphamapResolution), 0, terrainData.alphamapResolution - 1);
|
||||
|
||||
// 4. Sample TerrainData.GetAlphamaps(x, z, 1, 1) to get 3D float array of texture weights at that single point
|
||||
float[,,] splatmapData = terrainData.GetAlphamaps(mapX, mapZ, 1, 1);
|
||||
int numLayers = splatmapData.GetLength(2);
|
||||
textureWeights = new float[numLayers];
|
||||
|
||||
int dominantIndex = 0;
|
||||
float maxWeight = 0f;
|
||||
|
||||
for (int i = 0; i < numLayers; i++)
|
||||
{
|
||||
float weight = splatmapData[0, 0, i];
|
||||
textureWeights[i] = weight;
|
||||
if (weight > maxWeight)
|
||||
{
|
||||
maxWeight = weight;
|
||||
dominantIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return dominantIndex;
|
||||
}
|
||||
|
||||
private SurfaceSetting FindMatchingSetting(Terrain terrain, int textureIndex)
|
||||
{
|
||||
TerrainLayer currentLayer = null;
|
||||
if (terrain.terrainData != null && terrain.terrainData.terrainLayers != null && textureIndex < terrain.terrainData.terrainLayers.Length)
|
||||
{
|
||||
currentLayer = terrain.terrainData.terrainLayers[textureIndex];
|
||||
}
|
||||
|
||||
// 1. Check matching by TerrainLayer asset reference
|
||||
if (currentLayer != null)
|
||||
{
|
||||
foreach (var setting in surfaceSettings)
|
||||
{
|
||||
if (setting.terrainLayer != null && setting.terrainLayer == currentLayer)
|
||||
{
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check matching by texture index
|
||||
foreach (var setting in surfaceSettings)
|
||||
{
|
||||
if (setting.textureIndex == textureIndex)
|
||||
{
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: construct display name and return default surface
|
||||
string fallbackName = currentLayer != null ? currentLayer.name : $"Texture_{textureIndex}";
|
||||
currentSurfaceName = $"{fallbackName} (Unconfigured)";
|
||||
return defaultSurface;
|
||||
}
|
||||
|
||||
private void ApplySettingTargets(SurfaceSetting setting)
|
||||
{
|
||||
currentSurfaceName = setting.name;
|
||||
currentSpeedMultiplier = setting.speedMultiplier;
|
||||
|
||||
targetMaxSpeed = baseMaxSpeed * setting.speedMultiplier;
|
||||
targetAcceleration = baseAcceleration * setting.accelerationMultiplier;
|
||||
targetLinearDamping = setting.linearDamping;
|
||||
targetDynamicFriction = baseDynamicFriction * setting.frictionMultiplier;
|
||||
|
||||
// Apply physics material if specified
|
||||
if (setting.physicsMaterial != null && vehicleController != null && vehicleController.frictionMaterial != null)
|
||||
{
|
||||
vehicleController.frictionMaterial = setting.physicsMaterial;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySurfacePhysics()
|
||||
{
|
||||
float lerpFactor = Time.fixedDeltaTime * transitionSpeed;
|
||||
|
||||
// Smoothly blend vehicle controller properties
|
||||
if (vehicleController != null)
|
||||
{
|
||||
vehicleController.maxSpeed = Mathf.Lerp(vehicleController.maxSpeed, targetMaxSpeed, lerpFactor);
|
||||
vehicleController.acceleration = Mathf.Lerp(vehicleController.acceleration, targetAcceleration, lerpFactor);
|
||||
|
||||
if (vehicleController.frictionMaterial != null)
|
||||
{
|
||||
vehicleController.frictionMaterial.dynamicFriction = Mathf.Lerp(vehicleController.frictionMaterial.dynamicFriction, targetDynamicFriction, lerpFactor);
|
||||
}
|
||||
}
|
||||
|
||||
// Smoothly blend Rigidbody linear damping (drag)
|
||||
if (targetRigidbody != null)
|
||||
{
|
||||
targetRigidbody.linearDamping = Mathf.Lerp(targetRigidbody.linearDamping, targetLinearDamping, lerpFactor);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Vector3 rayStart = transform.position + rayOriginOffset;
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawLine(rayStart, rayStart + Vector3.down * rayDistance);
|
||||
Gizmos.DrawWireSphere(rayStart + Vector3.down * rayDistance, 0.15f);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/TerrainDetector.cs.meta
Normal file
2
Assets/Scripts/TerrainDetector.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 60d56134662b0294da2bbc24a1550a2a
|
||||
Reference in New Issue
Block a user