chunk 1: core gameplay scripts scenes runtime assets

This commit is contained in:
2026-04-06 11:02:34 +03:00
parent fa0388bc79
commit 0d11a097d8
703 changed files with 2292651 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// AIBlackboard — Per-AI knowledge store.
/// Holds perception results, targets, and state without allocations.
/// Updated by AIPerceptionSystem and read by AIActions/UtilityScorer.
/// </summary>
[System.Serializable]
public class AIBlackboard
{
// ─── Identity ────────────────────────────────────────────
public string teamTag;
public TeamMember.Team team;
// ─── Current Targets ─────────────────────────────────────
public Transform currentTarget;
public CashBag targetBag;
public TeamVault myVault;
public TeamVault enemyVault;
public CashCarrier nearestEnemyCarrier; // Priority target
public Transform escortTarget; // Ally to escort
// ─── Self State ──────────────────────────────────────────
public float currentHealth;
public float maxHealth;
public float healthPercent;
public bool isDead;
public int carriedBagCount;
public float totalCarriedValue;
public bool isCarrying;
// ─── Perception Results (updated by AIPerceptionSystem) ──
public readonly List<PerceivedEntity> perceivedEnemies = new List<PerceivedEntity>(8);
public readonly List<PerceivedEntity> perceivedAllies = new List<PerceivedEntity>(8);
public readonly List<CashBag> nearbyAvailableBags = new List<CashBag>(8);
public readonly List<CashBag> nearbyDroppedBags = new List<CashBag>(4);
public CashCarrier nearestAllyCarrier;
public int nearbyEnemyCount;
public int nearbyAllyCount;
public float nearestEnemyDistance;
public float nearestBagDistance;
// ─── Team State (from TeamBlackboard) ────────────────────
public float myTeamScore;
public float enemyTeamScore;
public bool myVaultUnderAttack;
public float scoreDifference; // positive = winning
// ─── Navigation ──────────────────────────────────────────
public Vector3 patrolDestination;
public bool hasPatrolDestination;
public float timeAtCurrentState;
/// <summary>Clear all perception data (called before perception update).</summary>
public void ClearPerception()
{
perceivedEnemies.Clear();
perceivedAllies.Clear();
nearbyAvailableBags.Clear();
nearbyDroppedBags.Clear();
nearestEnemyCarrier = null;
nearestAllyCarrier = null;
nearbyEnemyCount = 0;
nearbyAllyCount = 0;
nearestEnemyDistance = float.MaxValue;
nearestBagDistance = float.MaxValue;
}
}
/// <summary>
/// Data about a perceived entity (enemy or ally).
/// Reused from pool to avoid allocation.
/// </summary>
[System.Serializable]
public struct PerceivedEntity
{
public Transform transform;
public GameObject gameObject;
public float distance;
public float sqrDistance;
public float healthPercent;
public bool isCarryingBag;
public int carriedBagCount;
public bool isVisible; // In line of sight
public Vector3 lastKnownPosition;
public float timeSinceLastSeen;
}
/// <summary>
/// TeamBlackboard — Shared knowledge between all AI on a team.
/// Prevents clustering and enables coordination.
/// Singleton per team — accessed via TeamBlackboard.Get(teamTag).
/// </summary>
public class TeamBlackboard : MonoBehaviour
{
// ─── Static Access ───────────────────────────────────────
private static TeamBlackboard _playerTeam;
private static TeamBlackboard _enemyTeam;
public static TeamBlackboard Get(string teamTag)
{
if (teamTag == "Player")
{
if (_playerTeam == null)
{
var go = new GameObject("TeamBlackboard_Player");
_playerTeam = go.AddComponent<TeamBlackboard>();
_playerTeam._teamTag = "Player";
}
return _playerTeam;
}
else
{
if (_enemyTeam == null)
{
var go = new GameObject("TeamBlackboard_Enemy");
_enemyTeam = go.AddComponent<TeamBlackboard>();
_enemyTeam._teamTag = "Enemy";
}
return _enemyTeam;
}
}
public static void ClearAll()
{
if (_playerTeam != null) { Destroy(_playerTeam.gameObject); _playerTeam = null; }
if (_enemyTeam != null) { Destroy(_enemyTeam.gameObject); _enemyTeam = null; }
}
// ─── Team Data ───────────────────────────────────────────
[SerializeField] private string _teamTag;
/// <summary>Which bags are claimed by AI (prevents all AI chasing same bag).</summary>
public readonly Dictionary<CashBag, AIBrain> claimedBags = new Dictionary<CashBag, AIBrain>(8);
/// <summary>Which enemies are being targeted (limit engagement per enemy).</summary>
public readonly Dictionary<Transform, int> targetedEnemies = new Dictionary<Transform, int>(8);
/// <summary>Who on our team is carrying bags?</summary>
public readonly List<CashCarrier> teamCarriers = new List<CashCarrier>(4);
/// <summary>Is our vault under attack?</summary>
public bool vaultUnderAttack;
/// <summary>Our team's score.</summary>
public float teamScore;
// ─── Claim System ────────────────────────────────────────
/// <summary>Try to claim a bag for this AI. Returns true if claimed successfully.</summary>
public bool TryClaimBag(CashBag bag, AIBrain claimant)
{
if (bag == null) return false;
// Clean stale claims
if (claimedBags.TryGetValue(bag, out AIBrain existing))
{
if (existing == null || !existing.gameObject.activeInHierarchy)
{
claimedBags[bag] = claimant;
return true;
}
return existing == claimant; // Already claimed by us
}
claimedBags[bag] = claimant;
return true;
}
/// <summary>Release claim on a bag.</summary>
public void ReleaseBagClaim(AIBrain claimant)
{
CashBag toRemove = null;
foreach (var kvp in claimedBags)
{
if (kvp.Value == claimant) { toRemove = kvp.Key; break; }
}
if (toRemove != null) claimedBags.Remove(toRemove);
}
/// <summary>Is this bag already claimed by a teammate?</summary>
public bool IsBagClaimed(CashBag bag, AIBrain excludeSelf = null)
{
if (!claimedBags.TryGetValue(bag, out AIBrain claimant)) return false;
if (claimant == null || !claimant.gameObject.activeInHierarchy)
{
claimedBags.Remove(bag);
return false;
}
return claimant != excludeSelf;
}
/// <summary>Register an enemy as being targeted. Returns current count.</summary>
public int AddEnemyTarget(Transform enemy)
{
if (enemy == null) return 0;
if (!targetedEnemies.ContainsKey(enemy))
targetedEnemies[enemy] = 0;
targetedEnemies[enemy]++;
return targetedEnemies[enemy];
}
/// <summary>Unregister from targeting an enemy.</summary>
public void RemoveEnemyTarget(Transform enemy)
{
if (enemy == null) return;
if (targetedEnemies.ContainsKey(enemy))
{
targetedEnemies[enemy]--;
if (targetedEnemies[enemy] <= 0)
targetedEnemies.Remove(enemy);
}
}
/// <summary>How many teammates are targeting this enemy?</summary>
public int GetEnemyTargetCount(Transform enemy)
{
if (enemy == null) return 0;
return targetedEnemies.TryGetValue(enemy, out int count) ? count : 0;
}
/// <summary>Update team carriers list.</summary>
public void RefreshCarriers()
{
teamCarriers.Clear();
var allCarriers = EntityRegistry<CashCarrier>.All;
for (int i = 0; i < allCarriers.Count; i++)
{
if (allCarriers[i] != null && allCarriers[i].TeamTag == _teamTag && allCarriers[i].IsCarrying)
teamCarriers.Add(allCarriers[i]);
}
}
/// <summary>Clean stale entries (call periodically).</summary>
public void CleanStaleEntries()
{
// Clean bag claims
var staleBags = new List<CashBag>();
foreach (var kvp in claimedBags)
{
if (kvp.Key == null || kvp.Value == null || !kvp.Value.gameObject.activeInHierarchy)
staleBags.Add(kvp.Key);
}
for (int i = 0; i < staleBags.Count; i++)
claimedBags.Remove(staleBags[i]);
// Clean enemy targets
var staleTargets = new List<Transform>();
foreach (var kvp in targetedEnemies)
{
if (kvp.Key == null) staleTargets.Add(kvp.Key);
}
for (int i = 0; i < staleTargets.Count; i++)
targetedEnemies.Remove(staleTargets[i]);
}
private float _lastCleanTime;
private void Update()
{
if (Time.time - _lastCleanTime > 3f)
{
CleanStaleEntries();
RefreshCarriers();
_lastCleanTime = Time.time;
}
}
private void OnDestroy()
{
if (_playerTeam == this) _playerTeam = null;
if (_enemyTeam == this) _enemyTeam = null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f851e5eb528f0fa12a0efb0e7ecde6f6

View File

@@ -0,0 +1,352 @@
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// AIBrain — Central AI orchestrator. Replaces CashSystemAI.
/// Runs perception → utility scoring → action execution on a configurable tick.
///
/// Features:
/// - AI LOD (distance-based tick scaling)
/// - Zero FindObjectOfType calls
/// - Modular scoring via UtilityScorer
/// - Delegates combat to existing CharacterAIController
/// - Delegates navigation to AINavigationHelper
///
/// Attach to all AI characters (both player-team AI and enemy-team AI).
/// </summary>
public class AIBrain : MonoBehaviour
{
[Header("AI Settings")]
[SerializeField] private UtilityScorer.AIRole role = UtilityScorer.AIRole.Balanced;
[SerializeField] private bool autoAssignRole = true;
[Header("Tick Rate (AI LOD)")]
[SerializeField] private float baseTick = 0.3f; // Tier 0: on-screen, close
[SerializeField] private float midTick = 0.5f; // Tier 1: on-screen, far
[SerializeField] private float farTick = 1.0f; // Tier 2: off-screen
[SerializeField] private float lodMidDistance = 15f;
[SerializeField] private float lodFarDistance = 30f;
[Header("Behavior")]
[SerializeField] private float minActionDuration = 1.5f; // Minimum time before switching actions
// ─── Components ──────────────────────────────────────────
private AIPerceptionSystem _perception;
private AINavigationHelper _navigation;
private CharacterAIController _aiController;
private CashCarrier _carrier;
private HealthNew _health;
private TeamMember _teamMember;
// ─── AI State ────────────────────────────────────────────
private AIBlackboard _blackboard;
private TeamBlackboard _teamBlackboard;
private UtilityScorer _scorer;
private AIAction _currentAction;
private float _lastTickTime;
private float _currentTick;
private Transform _mainCamera;
private bool _combatEnabled;
// ─── Properties ──────────────────────────────────────────
public AIBlackboard Blackboard => _blackboard;
public AIAction CurrentAction => _currentAction;
public UtilityScorer.AIRole Role => role;
public string CurrentActionName => _scorer?.CurrentActionName ?? "Initializing";
// ─── Lifecycle ───────────────────────────────────────────
private void Awake()
{
// Ensure components exist
_perception = GetComponent<AIPerceptionSystem>();
if (_perception == null) _perception = gameObject.AddComponent<AIPerceptionSystem>();
_navigation = GetComponent<AINavigationHelper>();
if (_navigation == null) _navigation = gameObject.AddComponent<AINavigationHelper>();
}
private void Start()
{
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
{
enabled = false;
return;
}
// Cache components
_aiController = GetComponent<CharacterAIController>();
_carrier = GetComponent<CashCarrier>();
_health = GetComponent<HealthNew>();
_teamMember = GetComponent<TeamMember>();
// Find camera for LOD
if (Camera.main != null)
_mainCamera = Camera.main.transform;
// Initialize blackboard
_blackboard = new AIBlackboard();
_blackboard.teamTag = gameObject.tag;
_blackboard.team = _teamMember != null ? _teamMember.CurrentTeam : TeamMember.Team.EnemyTeam;
// Initialize perception
_perception.Initialize(_blackboard.teamTag);
// Find vaults (one-time, cached)
FindVaults();
// Auto-assign role
if (autoAssignRole)
AssignRandomRole();
// Initialize scorer
_scorer = new UtilityScorer(role);
// Get team blackboard
_teamBlackboard = TeamBlackboard.Get(_blackboard.teamTag);
_currentTick = baseTick;
// Subscribe to events
GameEvents.OnCombatEnabled += OnCombatEnabled;
GameEvents.OnVaultUnderAttack += OnVaultAttacked;
GameEvents.OnMatchEnd += OnMatchEnd;
}
private void OnDestroy()
{
GameEvents.OnCombatEnabled -= OnCombatEnabled;
GameEvents.OnVaultUnderAttack -= OnVaultAttacked;
GameEvents.OnMatchEnd -= OnMatchEnd;
// Release claims
_teamBlackboard?.ReleaseBagClaim(this);
}
private void Update()
{
if (!_combatEnabled) return;
if (_health != null && _health.isDead) return;
// AI LOD — adjust tick rate based on distance to camera
UpdateAILOD();
// Tick check
if (Time.time - _lastTickTime < _currentTick) return;
_lastTickTime = Time.time;
// 1. Update self state on blackboard
UpdateSelfState();
// 2. Run perception
_perception.UpdatePerception(_blackboard);
// 3. Update team state
UpdateTeamState();
// 4. Score and select action
AIAction bestAction = _scorer.Evaluate(this, _blackboard, _teamBlackboard);
// 5. Execute action
if (bestAction != null)
{
_currentAction = bestAction;
_currentAction.Execute(this, _blackboard, _teamBlackboard);
}
}
// ─── State Updates ───────────────────────────────────────
private void UpdateSelfState()
{
if (_health != null)
{
_blackboard.currentHealth = _health.currentHealth;
_blackboard.maxHealth = _health.maxHealth;
_blackboard.healthPercent = _health.maxHealth > 0 ? _health.currentHealth / _health.maxHealth : 1f;
_blackboard.isDead = _health.isDead;
}
if (_carrier != null)
{
_blackboard.carriedBagCount = _carrier.BagCount;
_blackboard.totalCarriedValue = _carrier.TotalValue;
_blackboard.isCarrying = _carrier.IsCarrying;
}
}
private void UpdateTeamState()
{
if (_teamBlackboard != null)
{
_blackboard.myVaultUnderAttack = _teamBlackboard.vaultUnderAttack;
_blackboard.myTeamScore = _teamBlackboard.teamScore;
}
// Get enemy team score
string enemyTag = _blackboard.teamTag == "Player" ? "Enemy" : "Player";
TeamBlackboard enemyBoard = TeamBlackboard.Get(enemyTag);
if (enemyBoard != null)
{
_blackboard.enemyTeamScore = enemyBoard.teamScore;
}
_blackboard.scoreDifference = _blackboard.myTeamScore - _blackboard.enemyTeamScore;
}
private void UpdateAILOD()
{
if (_mainCamera == null)
{
_currentTick = baseTick;
return;
}
float distToCamera = Vector3.Distance(transform.position, _mainCamera.position);
if (distToCamera > lodFarDistance)
_currentTick = farTick;
else if (distToCamera > lodMidDistance)
_currentTick = midTick;
else
_currentTick = baseTick;
}
private void FindVaults()
{
var vaults = EntityRegistry<TeamVault>.All;
for (int i = 0; i < vaults.Count; i++)
{
if (vaults[i] == null) continue;
if (vaults[i].TeamTag == _blackboard.teamTag)
_blackboard.myVault = vaults[i];
else
_blackboard.enemyVault = vaults[i];
}
// Fallback to FindObjectsOfType (only at initialization)
if (_blackboard.myVault == null || _blackboard.enemyVault == null)
{
TeamVault[] allVaults = FindObjectsOfType<TeamVault>();
foreach (var vault in allVaults)
{
if (vault.TeamTag == _blackboard.teamTag)
_blackboard.myVault = vault;
else
_blackboard.enemyVault = vault;
}
}
}
private void AssignRandomRole()
{
float rand = Random.value;
if (rand < 0.35f) role = UtilityScorer.AIRole.Balanced;
else if (rand < 0.60f) role = UtilityScorer.AIRole.Collector;
else if (rand < 0.80f) role = UtilityScorer.AIRole.Defender;
else role = UtilityScorer.AIRole.Aggressor;
}
// ─── Public API (called by AIActions) ────────────────────
/// <summary>Navigate to a world position.</summary>
public void NavigateTo(Vector3 position)
{
_navigation.SetDestination(position);
}
/// <summary>Stop all navigation.</summary>
public void StopNavigation()
{
_navigation.Stop();
}
/// <summary>Has the AI reached its current navigation destination?</summary>
public bool HasReachedDestination()
{
return _navigation.HasReachedDestination();
}
/// <summary>Engage a target in combat (delegates to CharacterAIController).</summary>
public void EngageTarget(Transform target)
{
if (_aiController != null)
{
_aiController.SetTarget(target.gameObject);
_aiController.NavigateTo(target.position);
_aiController.attack = true;
}
else
{
// Fallback: just navigate toward
NavigateTo(target.position);
}
}
/// <summary>Disengage from combat.</summary>
public void DisengageTarget()
{
if (_aiController != null)
{
_aiController.Stop();
}
}
// ─── Event Handlers ──────────────────────────────────────
private void OnCombatEnabled(bool enabled)
{
_combatEnabled = enabled;
}
private void OnVaultAttacked(TeamVault vault)
{
if (vault.TeamTag == _blackboard.teamTag)
{
_blackboard.myVaultUnderAttack = true;
if (_teamBlackboard != null)
_teamBlackboard.vaultUnderAttack = true;
// Force immediate decision (bypass tick timer)
_lastTickTime = 0;
}
}
private void OnMatchEnd(string winnerTag)
{
_combatEnabled = false;
StopNavigation();
}
// ─── Debug ───────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (_blackboard == null) return;
// Show current action
UnityEditor.Handles.Label(transform.position + Vector3.up * 2.5f,
$"AI: {CurrentActionName}\n" +
$"Role: {role}\n" +
$"HP: {_blackboard.healthPercent:P0}\n" +
$"Bags: {_blackboard.carriedBagCount}\n" +
$"Enemies: {_blackboard.nearbyEnemyCount}\n" +
$"Tick: {_currentTick:F2}s");
// Show target
if (_blackboard.currentTarget != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position + Vector3.up, _blackboard.currentTarget.position + Vector3.up);
}
// Show target bag
if (_blackboard.targetBag != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawLine(transform.position + Vector3.up, _blackboard.targetBag.transform.position);
}
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5557a60f10e7a97e79823d421b552c2d

View File

@@ -0,0 +1,241 @@
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// AIPerceptionSystem — Unified vision/detection system for AI.
/// Uses Physics.OverlapSphereNonAlloc for zero-allocation detection.
/// Populates the AIBlackboard with perception results.
///
/// Call UpdatePerception() on the AI tick (not every frame).
/// </summary>
public class AIPerceptionSystem : MonoBehaviour
{
[Header("Vision Settings")]
[SerializeField] private float detectionRadius = 18f;
[SerializeField] private float fovAngle = 160f; // Forward cone
[SerializeField] private float closeRangeRadius = 5f; // Always detect within this range (no FOV check)
[Header("Bag Detection")]
[SerializeField] private float bagDetectionRadius = 25f;
[Header("Line of Sight")]
[SerializeField] private bool requireLineOfSight = true;
[SerializeField] private LayerMask obstructionLayers;
[SerializeField] private float losHeightOffset = 1.2f; // Eye height
[Header("Memory")]
[SerializeField] private float memoryDuration = 5f; // Remember entities for this long after losing sight
// ─── Detection Buffer (zero-alloc) ───────────────────────
private static readonly Collider[] _overlapBuffer = new Collider[32];
// ─── Threat Memory ───────────────────────────────────────
private readonly Dictionary<Transform, float> _lastSeenTime = new Dictionary<Transform, float>(16);
private readonly Dictionary<Transform, Vector3> _lastKnownPositions = new Dictionary<Transform, Vector3>(16);
// ─── Component Cache ─────────────────────────────────────
private Transform _transform;
private string _teamTag;
private void Awake()
{
_transform = transform;
}
public void Initialize(string teamTag)
{
_teamTag = teamTag;
}
/// <summary>
/// Update perception and populate blackboard.
/// Call this on the AI decision tick, NOT every frame.
/// </summary>
public void UpdatePerception(AIBlackboard blackboard)
{
blackboard.ClearPerception();
Vector3 myPos = _transform.position;
Vector3 eyePos = myPos + Vector3.up * losHeightOffset;
// ─── Detect Characters ───────────────────────────────
var allMembers = TeamMember.AllMembers;
for (int i = 0; i < allMembers.Count; i++)
{
TeamMember member = allMembers[i];
if (member == null || member.gameObject == gameObject) continue;
Transform target = member.transform;
float sqrDist = (target.position - myPos).sqrMagnitude;
float dist = Mathf.Sqrt(sqrDist);
// Range check
if (dist > detectionRadius) continue;
// FOV check (skip for close range)
bool inFOV = dist <= closeRangeRadius || IsInFOV(target.position);
// LOS check
bool hasLOS = !requireLineOfSight || dist <= closeRangeRadius || CheckLineOfSight(eyePos, target.position + Vector3.up * losHeightOffset);
bool isVisible = inFOV && hasLOS;
// Update memory
if (isVisible)
{
_lastSeenTime[target] = Time.time;
_lastKnownPositions[target] = target.position;
}
// Check memory (even if not currently visible)
bool isRemembered = false;
float timeSinceLastSeen = float.MaxValue;
Vector3 lastKnownPos = target.position;
if (_lastSeenTime.TryGetValue(target, out float lastSeen))
{
timeSinceLastSeen = Time.time - lastSeen;
isRemembered = timeSinceLastSeen < memoryDuration;
if (_lastKnownPositions.TryGetValue(target, out Vector3 lkp))
lastKnownPos = lkp;
}
if (!isVisible && !isRemembered) continue;
// Build perceived entity
HealthNew health = member.GetComponent<HealthNew>();
CashCarrier carrier = member.GetComponent<CashCarrier>();
PerceivedEntity entity = new PerceivedEntity
{
transform = target,
gameObject = member.gameObject,
distance = dist,
sqrDistance = sqrDist,
healthPercent = health != null ? (health.currentHealth / health.maxHealth) : 1f,
isCarryingBag = carrier != null && carrier.IsCarrying,
carriedBagCount = carrier != null ? carrier.BagCount : 0,
isVisible = isVisible,
lastKnownPosition = lastKnownPos,
timeSinceLastSeen = timeSinceLastSeen
};
bool isEnemy = member.gameObject.tag != _teamTag;
if (isEnemy)
{
blackboard.perceivedEnemies.Add(entity);
blackboard.nearbyEnemyCount++;
if (dist < blackboard.nearestEnemyDistance)
blackboard.nearestEnemyDistance = dist;
// Track nearest enemy carrier
if (entity.isCarryingBag && carrier != null)
{
if (blackboard.nearestEnemyCarrier == null ||
dist < Vector3.Distance(myPos, blackboard.nearestEnemyCarrier.transform.position))
{
blackboard.nearestEnemyCarrier = carrier;
}
}
}
else
{
blackboard.perceivedAllies.Add(entity);
blackboard.nearbyAllyCount++;
// Track nearest ally carrier (for escort)
if (entity.isCarryingBag && carrier != null)
{
if (blackboard.nearestAllyCarrier == null ||
dist < Vector3.Distance(myPos, blackboard.nearestAllyCarrier.transform.position))
{
blackboard.nearestAllyCarrier = carrier;
}
}
}
}
// ─── Detect Cash Bags ─────────────────────────────────
var allBags = EntityRegistry<CashBag>.All;
for (int i = 0; i < allBags.Count; i++)
{
CashBag bag = allBags[i];
if (bag == null || bag.IsCarried) continue;
float dist = Vector3.Distance(myPos, bag.transform.position);
if (dist > bagDetectionRadius) continue;
if (dist < blackboard.nearestBagDistance)
blackboard.nearestBagDistance = dist;
if (bag.IsAvailable)
blackboard.nearbyAvailableBags.Add(bag);
else if (bag.IsDropped)
blackboard.nearbyDroppedBags.Add(bag);
}
}
// ─── Vision Helpers ──────────────────────────────────────
private bool IsInFOV(Vector3 targetPos)
{
Vector3 dirToTarget = (targetPos - _transform.position).normalized;
dirToTarget.y = 0; // Horizontal only
float angle = Vector3.Angle(_transform.forward, dirToTarget);
return angle <= fovAngle * 0.5f;
}
private bool CheckLineOfSight(Vector3 from, Vector3 to)
{
Vector3 dir = to - from;
float dist = dir.magnitude;
if (dist < 0.1f) return true;
return !Physics.Raycast(from, dir.normalized, dist, obstructionLayers);
}
// ─── Memory Management ───────────────────────────────────
/// <summary>Clear all threat memory (call on state reset).</summary>
public void ClearMemory()
{
_lastSeenTime.Clear();
_lastKnownPositions.Clear();
}
/// <summary>Get last known position of a target (for pursuit).</summary>
public bool TryGetLastKnownPosition(Transform target, out Vector3 position)
{
return _lastKnownPositions.TryGetValue(target, out position);
}
// ─── Debug ───────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (_transform == null) _transform = transform;
// Detection radius
Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
Gizmos.DrawWireSphere(_transform.position, detectionRadius);
// Close range
Gizmos.color = new Color(1f, 0.5f, 0f, 0.15f);
Gizmos.DrawWireSphere(_transform.position, closeRangeRadius);
// FOV cone
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
Vector3 fovLeft = Quaternion.Euler(0, -fovAngle * 0.5f, 0) * _transform.forward * detectionRadius;
Vector3 fovRight = Quaternion.Euler(0, fovAngle * 0.5f, 0) * _transform.forward * detectionRadius;
Gizmos.DrawLine(_transform.position, _transform.position + fovLeft);
Gizmos.DrawLine(_transform.position, _transform.position + fovRight);
// Bag detection radius
Gizmos.color = new Color(1f, 0.84f, 0f, 0.05f);
Gizmos.DrawWireSphere(_transform.position, bagDetectionRadius);
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 09984091d0b1426349f45490e2b656e7