Files
CartoonCopChase/Assets/Scripts/AIController.cs

615 lines
23 KiB
C#
Raw Normal View History

2026-08-28 16:09:06 +01:00
using UnityEngine;
using UnityEngine.AI;
using ArcadeVP;
public class AIController : MonoBehaviour
{
public enum AIState { Patrol, Pursue, Searching, Unstuck, Destroyed }
public enum AITactic { Chase, Intercept }
2026-08-28 16:09:06 +01:00
[Header("Target")]
public ArcadeVehicleController player;
private CarHealth playerHealth;
private ArcadeVehicleController aiVehicle;
[Header("Current State (Debug)")]
public AIState currentState = AIState.Patrol;
public AITactic currentTactic = AITactic.Chase;
private float tacticTimer = 0f;
2026-08-28 16:09:06 +01:00
[Header("Vision & Senses")]
[Tooltip("Maximum distance the AI can spot the player.")]
public float visionRange = 120f;
[Tooltip("Field of view angle. E.g. 120 means 60 degrees left and right.")]
[Range(0, 360)] public float visionAngle = 120f;
[Tooltip("Layers that block Line of Sight (buildings, walls).")]
public LayerMask obstacleLayers;
[Header("Search System")]
[Tooltip("How long the player must be hidden before AI switches to Search mode.")]
public float timeToLoseTarget = 1.5f;
[Tooltip("How long to search the last known area before giving up and patrolling.")]
public float searchDuration = 10.0f;
private float sightLostTimer = 0f;
private float searchTimer = 0f;
private Vector3 lastKnownPosition;
// --- POLICE RADIO (HIVE MIND) ---
public static Vector3 globalLastKnownPosition;
public static float globalLastSpottedTime = -999f;
2026-08-28 16:09:06 +01:00
[Header("Navigation (NavMesh)")]
[Tooltip("How often the AI recalculates its path around buildings.")]
public float pathRecalculationRate = 0.5f;
private float pathTimer = 0f;
private NavMeshPath currentPath;
[Header("Aggression Settings (Pursuit)")]
public float forcedTurnSpeed = 8f;
public float minRamSpeed = 10f;
[Header("Unstuck System")]
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;
2026-08-28 16:09:06 +01:00
[Header("Patrol Settings")]
private Vector3 currentPatrolPoint;
private bool hasPatrolPoint = false;
[Header("Rubber-Banding (Catch-Up)")]
private float baseMaxSpeed;
private float baseAcceleration;
private int aiID;
2026-08-28 16:09:06 +01:00
void Start()
{
aiID = Random.Range(1, 100000);
2026-08-28 16:09:06 +01:00
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);
2026-08-28 16:09:06 +01:00
currentPath = new NavMeshPath();
2026-08-28 16:09:06 +01:00
if (player == null)
{
ArcadeVehicleController[] cars = FindObjectsByType<ArcadeVehicleController>(FindObjectsInactive.Exclude);
2026-08-28 16:09:06 +01:00
foreach (ArcadeVehicleController car in cars)
{
if (car.CompareTag("Player"))
{
player = car;
break;
}
}
}
if (player != null)
{
playerHealth = player.GetComponent<CarHealth>();
lastKnownPosition = player.transform.position; // "scanner" start
}
}
void FixedUpdate()
{
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;
}
2026-08-28 16:09:06 +01:00
// If player is destroyed, ignore them and just patrol
if (playerHealth != null && playerHealth.isDestroyed)
{
currentState = AIState.Patrol;
ExecutePatrol(aiVehicle.carBody.transform, aiVehicle.carBody.transform.position, aiVehicle.carBody.linearVelocity.magnitude);
return;
}
Transform aiTransform = aiVehicle.carBody.transform;
Transform playerTransform = player.carBody != null ? player.carBody.transform : player.transform;
Vector3 aiPos = aiTransform.position;
Vector3 playerPos = playerTransform.position;
float currentSpeed = aiVehicle.carBody.linearVelocity.magnitude;
// --- 1. UNSTUCK RECOVERY ---
if (unstuckReverseTimer > 0f)
{
unstuckReverseTimer -= Time.fixedDeltaTime;
aiVehicle.ProvideInputs(0f, -1f, 0f); // Reverse straight back
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;
}
2026-08-28 16:09:06 +01:00
if (currentSpeed < 1.0f && currentState != AIState.Patrol)
{
stuckTimer += Time.fixedDeltaTime;
if (stuckTimer > 1.5f)
{
unstuckReverseTimer = 1.5f;
stuckTimer = 0f;
return;
}
}
else
{
stuckTimer = 0f;
}
// --- 2. VISION & STATE MACHINE (Radio Integrated) ---
2026-08-28 16:09:06 +01:00
bool hasLoS = CheckLineOfSight(aiTransform, playerTransform);
if (hasLoS)
{
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)
2026-08-28 16:09:06 +01:00
}
else if (currentState == AIState.Pursue)
{
// No one has seen the player recently. Start the grace period.
2026-08-28 16:09:06 +01:00
sightLostTimer += Time.fixedDeltaTime;
// 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)
2026-08-28 16:09:06 +01:00
{
currentState = AIState.Searching;
searchTimer = 0f;
}
}
else if (currentState == AIState.Searching)
{
searchTimer += Time.fixedDeltaTime;
if (searchTimer >= searchDuration)
{
currentState = AIState.Patrol; // Give up
}
}
// --- 3. EXECUTE BEHAVIOR BASED ON STATE ---
if (currentState == AIState.Pursue)
{
ExecutePursuit(aiTransform, aiPos, playerPos, currentSpeed);
}
else if (currentState == AIState.Searching)
{
ExecuteSearch(aiTransform, aiPos, currentSpeed);
}
else if (currentState == AIState.Patrol)
{
ExecutePatrol(aiTransform, aiPos, currentSpeed);
}
}
private bool CheckLineOfSight(Transform aiTransform, Transform playerTransform)
{
Vector3 dirToPlayer = playerTransform.position - aiTransform.position;
float dist = dirToPlayer.magnitude;
// Out of absolute range
if (dist > visionRange) return false;
// Check Field of View
float angle = Vector3.Angle(aiTransform.forward, dirToPlayer);
if (angle > visionAngle * 0.5f)
{
// Proximity sense (hearing/radar scanner): Even if behind them, if very close they detect you
if (dist > 15f) return false;
}
// Raycast to check for buildings/walls blocking the view
Vector3 rayStart = aiTransform.position + Vector3.up * 1f;
Vector3 rayEnd = playerTransform.position + Vector3.up * 1f;
Vector3 rayDir = (rayEnd - rayStart).normalized;
if (Physics.Raycast(rayStart, rayDir, out RaycastHit hit, dist, obstacleLayers))
{
if (!hit.collider.CompareTag("Player") && !hit.collider.transform.IsChildOf(playerTransform))
{
return false;
}
}
return true;
}
private Vector3 GetNavMeshWaypoint(Vector3 start, Vector3 destination)
{
pathTimer += Time.fixedDeltaTime;
if (pathTimer >= pathRecalculationRate || currentPath == null || currentPath.corners.Length == 0)
{
pathTimer = 0f;
NavMesh.CalculatePath(start, destination, NavMesh.AllAreas, currentPath);
}
if (currentPath != null && currentPath.corners.Length > 1)
{
// corners[0] is our position. corners[1] is the next turn/waypoint
Vector3 nextCorner = currentPath.corners[1];
// If we are very close to the next corner, aim for the one after it to smooth steering
if (Vector3.Distance(start, nextCorner) < 8f && currentPath.corners.Length > 2)
{
nextCorner = currentPath.corners[2];
}
return nextCorner;
}
// Fallback to straight line if NavMesh fails
return destination;
}
private void ExecutePursuit(Transform aiTransform, Vector3 aiPos, Vector3 playerPos, float currentSpeed)
{
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;
}
2026-08-28 16:09:06 +01:00
// If close and have line of sight, bypass NavMesh and attack directly!
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;
}
2026-08-28 16:09:06 +01:00
if (isCloseAndVisible)
{
Vector3 targetPoint = playerPos;
Transform playerTransform = player.carBody != null ? player.carBody.transform : player.transform;
Vector3 localAIPos = playerTransform.InverseTransformPoint(aiPos);
// --- 2. AGGRESSIVE FLANKING & PINNING ---
if (localAIPos.z > 0f && distance < 15f)
2026-08-28 16:09:06 +01:00
{
// 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)
2026-08-28 16:09:06 +01:00
{
// AI is on the left, violently ram the player's front-left quarter panel
targetPoint = playerPos + playerTransform.forward * 2f + playerTransform.right * 1f;
}
else
{
// AI is on the right, violently ram the player's front-right quarter panel
targetPoint = playerPos + playerTransform.forward * 2f - playerTransform.right * 1f;
2026-08-28 16:09:06 +01:00
}
}
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;
2026-08-28 16:09:06 +01:00
}
else
{
// General Ramming / Catching up
float leadTime = Mathf.Clamp(distance / Mathf.Max(currentSpeed, minRamSpeed), 0f, 1.5f);
2026-08-28 16:09:06 +01:00
targetPoint = playerPos + (playerVelocity * leadTime);
// 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);
2026-08-28 16:09:06 +01:00
targetPoint += playerTransform.right * sideOffset;
}
DriveTowardsPoint(aiTransform, aiPos, targetPoint, currentSpeed, true);
}
else
{
// --- 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);
2026-08-28 16:09:06 +01:00
DriveTowardsPoint(aiTransform, aiPos, waypoint, currentSpeed, true);
// Draw path for debugging
if (currentPath != null && currentPath.corners.Length > 1)
{
for (int i = 0; i < currentPath.corners.Length - 1; i++)
Debug.DrawLine(currentPath.corners[i], currentPath.corners[i+1], Color.magenta);
}
}
lastKnownPosition = playerPos;
}
private void ExecuteSearch(Transform aiTransform, Vector3 aiPos, float currentSpeed)
{
// Use NavMesh to safely drive to the last known position
Vector3 waypoint = GetNavMeshWaypoint(aiPos, lastKnownPosition);
DriveTowardsPoint(aiTransform, aiPos, waypoint, currentSpeed, false);
}
private void ExecutePatrol(Transform aiTransform, Vector3 aiPos, float currentSpeed)
{
if (!hasPatrolPoint)
{
hasPatrolPoint = FindNewPatrolPoint(aiPos, aiTransform);
}
if (hasPatrolPoint)
{
Vector3 waypoint = GetNavMeshWaypoint(aiPos, currentPatrolPoint);
DriveTowardsPoint(aiTransform, aiPos, waypoint, currentSpeed, false);
// Reached point or stuck
if (Vector3.Distance(aiPos, currentPatrolPoint) < 15f || stuckTimer > 1.0f)
{
if (PatrolManager.Instance != null) PatrolManager.Instance.UnregisterTarget(this);
hasPatrolPoint = false;
}
}
else
{
// Fallback Cruise
float maxSpeedMs = 30f / 3.6f;
float throttle = currentSpeed > maxSpeedMs ? 0f : 0.4f;
float brake = currentSpeed > maxSpeedMs + 2f ? 0.5f : 0f;
aiVehicle.ProvideInputs(0.1f, throttle, brake);
}
}
private bool FindNewPatrolPoint(Vector3 aiPos, Transform aiTransform)
{
// Pick a completely random point in front of us using NavMesh
Vector3 randomDir = Quaternion.Euler(0, Random.Range(-90f, 90f), 0) * aiTransform.forward;
Vector3 randomPos = aiPos + (randomDir * Random.Range(60f, 150f));
// Snap it to the nearest valid NavMesh location (road, drivable, etc)
if (NavMesh.SamplePosition(randomPos, out NavMeshHit hit, 50f, NavMesh.AllAreas))
{
if (PatrolManager.Instance != null)
{
if (!PatrolManager.Instance.IsPointCrowded(hit.position, this))
{
currentPatrolPoint = hit.position;
PatrolManager.Instance.RegisterTarget(this, hit.position);
return true;
}
}
else
{
currentPatrolPoint = hit.position;
return true;
}
}
return false;
}
private void DriveTowardsPoint(Transform aiTransform, Vector3 aiPos, Vector3 targetPoint, float currentSpeed, bool aggressive)
{
Vector3 dirToTarget = (targetPoint - aiPos).normalized;
dirToTarget.y = 0;
if (dirToTarget.sqrMagnitude < 0.01f)
{
aiVehicle.ProvideInputs(0f, 0.5f, 0f);
return;
}
// --- 1. ANTI-CRASH AVOIDANCE & LANE OFFSET ---
2026-08-28 16:09:06 +01:00
Vector3 avoidanceOffset = Vector3.zero;
bool needsToBrakeForAlly = false;
float avoidDist = aggressive ? 15f : 20f;
2026-08-28 16:09:06 +01:00
// 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);
2026-08-28 16:09:06 +01:00
// 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 ? 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
2026-08-28 16:09:06 +01:00
}
}
// Ray Left
if (Physics.Raycast(aiPos + Vector3.up - aiTransform.right * 1.5f, aiTransform.forward, out RaycastHit hitL, avoidDist))
{
if (hitL.collider.GetComponentInParent<AIController>() != null)
{
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
2026-08-28 16:09:06 +01:00
}
}
// Ray Right
if (Physics.Raycast(aiPos + Vector3.up + aiTransform.right * 1.5f, aiTransform.forward, out RaycastHit hitR, avoidDist))
{
if (hitR.collider.GetComponentInParent<AIController>() != null)
{
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
2026-08-28 16:09:06 +01:00
}
}
if (avoidanceOffset != Vector3.zero)
{
dirToTarget = (dirToTarget + avoidanceOffset).normalized;
}
// --- 2. STEERING & THROTTLE ---
float angleToTarget = Vector3.SignedAngle(aiTransform.forward, dirToTarget, Vector3.up);
float steerInput = Mathf.Clamp(angleToTarget / 40f, -1f, 1f);
float throttleInput = aggressive ? 1f : 0.6f;
float brakeInput = 0f;
if (Mathf.Abs(angleToTarget) > 100f)
{
if (currentSpeed > 5f)
{
throttleInput = 0f;
brakeInput = 1f;
}
else
{
throttleInput = -1f;
}
}
else if (Mathf.Abs(angleToTarget) > 60f)
{
throttleInput = aggressive ? 0.5f : 0.3f;
}
// --- 3. SPEED LIMITERS & AVOIDANCE BRAKING ---
if (needsToBrakeForAlly)
{
throttleInput = 0f;
brakeInput = aggressive ? 0.4f : 0.8f; // Try not to rear-end the guy in front
}
else if (!aggressive)
{
float maxSpeedMs = 30f / 3.6f;
if (currentSpeed > maxSpeedMs)
{
throttleInput = 0f;
if (currentSpeed > maxSpeedMs + 2f) brakeInput = 0.5f;
}
}
aiVehicle.ProvideInputs(steerInput, throttleInput, brakeInput);
// --- 4. FORCED ROTATION (Overdrive Steering) ---
2026-08-28 16:09:06 +01:00
// 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;
// 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);
2026-08-28 16:09:06 +01:00
aiVehicle.carBody.MoveRotation(Quaternion.Euler(currentEuler.x, newY, currentEuler.z));
}
}
}