chunk 1: core gameplay scripts scenes runtime assets
This commit is contained in:
966
Assets/Scripts/Character/CharacterAIController.cs
Normal file
966
Assets/Scripts/Character/CharacterAIController.cs
Normal file
@@ -0,0 +1,966 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Unified AI Controller that handles both enemy and teammate AI.
|
||||
/// Replaces EnemyScript + EnemyNavigation with NavMeshAgent-based movement.
|
||||
/// Works with CashSystemAI for Cash System mode decisions.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class CharacterAIController : MonoBehaviour
|
||||
{
|
||||
#region Enums
|
||||
|
||||
public enum AIState
|
||||
{
|
||||
Idle,
|
||||
Chasing,
|
||||
Attacking,
|
||||
Retreating,
|
||||
Blocking,
|
||||
Dead,
|
||||
Patrolling // NEW: wander when no target in range
|
||||
}
|
||||
|
||||
public enum AIMode
|
||||
{
|
||||
Practice, // 1v1 mode - simple attack behavior
|
||||
CashSystem // Team mode - coordinate with CashSystemAI
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
[Header("AI Configuration")]
|
||||
[SerializeField] private CharacterStats characterStats;
|
||||
[SerializeField] private AIMode currentMode = AIMode.Practice;
|
||||
|
||||
[Header("Combat Permission")]
|
||||
[Tooltip("Combat permission flag — toggled by RoundsScript/PlayerManagerNew between rounds")]
|
||||
public bool attack = true;
|
||||
|
||||
[Header("Movement Settings")]
|
||||
[Tooltip("Base movement speed (replaces EnemyNavigation.speed)")]
|
||||
public float speed = 5f;
|
||||
[SerializeField] private float stoppingDistance = 1.5f;
|
||||
[SerializeField] private float rotationSpeed = 120f;
|
||||
|
||||
[Header("Combat Settings")]
|
||||
[SerializeField] private float attackRange = 2f;
|
||||
[SerializeField] private float chaseRange = 20f;
|
||||
[SerializeField] private float attackCooldown = 1.5f;
|
||||
[SerializeField] private float randomAttackVariation = 0.5f;
|
||||
|
||||
[Header("Retreat/Block Settings")]
|
||||
[SerializeField] private float retreatDistance = 4f;
|
||||
[SerializeField] private float retreatDuration = 1.5f;
|
||||
[SerializeField] private float blockDuration = 1.0f;
|
||||
[SerializeField] private float retreatChance = 0.15f;
|
||||
[SerializeField] private float blockChance = 0.20f;
|
||||
[SerializeField] private float healthRetreatThreshold = 30f;
|
||||
|
||||
[Header("Decision Loop")]
|
||||
[SerializeField] private float decisionInterval = 0.3f;
|
||||
[SerializeField] private float farDecisionInterval = 0.8f;
|
||||
[SerializeField] private float lodDistance = 25f;
|
||||
|
||||
[Header("Patrol Settings (Practice Mode)")]
|
||||
[SerializeField] private float patrolRadius = 10f;
|
||||
[SerializeField] private float patrolPauseMin = 1f;
|
||||
[SerializeField] private float patrolPauseMax = 2.5f;
|
||||
|
||||
[Header("Grounding")]
|
||||
[Tooltip("Layers to raycast against for ground detection. Must exclude character layers to prevent floating.")]
|
||||
[SerializeField] private LayerMask groundLayerMask = 0;
|
||||
|
||||
[Header("State")]
|
||||
[SerializeField] private AIState currentState = AIState.Idle;
|
||||
[SerializeField] private GameObject currentTarget;
|
||||
|
||||
// Cached components
|
||||
private Animator anim;
|
||||
private NavMeshAgent navAgent;
|
||||
private TeamMember teamMember;
|
||||
private AnimationManager animationManager;
|
||||
private CharacterAttacks characterAttacks;
|
||||
private HealthNew health;
|
||||
private CashSystemAI cashSystemAI;
|
||||
|
||||
// Cached external references
|
||||
private PlayerManagerNew cachedPlayerManager;
|
||||
private Camera cachedCamera;
|
||||
|
||||
// Internal state
|
||||
private float lastAttackTime;
|
||||
private float distanceToTarget;
|
||||
private bool isAttacking;
|
||||
private float retreatEndTime;
|
||||
private float blockEndTime;
|
||||
private Vector3 retreatDirection;
|
||||
private Coroutine decisionCoroutine;
|
||||
|
||||
// Patrol state
|
||||
private Vector3 patrolTarget;
|
||||
private bool hasPatrolTarget;
|
||||
private float patrolPauseEndTime;
|
||||
|
||||
// Cash System combat — set by CashSystemAI when it wants us to fight
|
||||
private bool cashSystemCombatMode;
|
||||
|
||||
// Gravity handling
|
||||
private float gvelocity;
|
||||
[SerializeField] private float gravity = -9.81f;
|
||||
[SerializeField] private float gravityScale = 0.75f;
|
||||
private bool jump;
|
||||
|
||||
// Scale protection: animation clips with baked scale curves from FBX import
|
||||
// can shrink the character model. Save and restore original scale each frame.
|
||||
private Vector3 originalScale;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
originalScale = transform.localScale;
|
||||
CacheComponents();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InitializeAI();
|
||||
DetermineMode();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Reset state when AI is enabled
|
||||
currentState = AIState.Idle;
|
||||
isAttacking = false;
|
||||
|
||||
// Re-warp to NavMesh when re-enabled (e.g., after control switch)
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled)
|
||||
{
|
||||
WarpToNavMesh();
|
||||
}
|
||||
|
||||
// Start the coroutine-based decision loop
|
||||
if (decisionCoroutine != null) StopCoroutine(decisionCoroutine);
|
||||
decisionCoroutine = StartCoroutine(AIDecisionLoop());
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (decisionCoroutine != null)
|
||||
{
|
||||
StopCoroutine(decisionCoroutine);
|
||||
decisionCoroutine = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FixedUpdate handles gravity and grounding every physics tick.
|
||||
/// This MUST run independently of the AI decision loop so characters
|
||||
/// stay grounded even before combat starts (attack == false).
|
||||
/// </summary>
|
||||
private void FixedUpdate()
|
||||
{
|
||||
ApplyGravity();
|
||||
EnsureUprightCharacter();
|
||||
|
||||
// Protect against animation clips overwriting scale
|
||||
if (transform.localScale != originalScale)
|
||||
{
|
||||
transform.localScale = originalScale;
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheComponents()
|
||||
{
|
||||
anim = GetComponent<Animator>();
|
||||
navAgent = GetComponent<NavMeshAgent>();
|
||||
teamMember = GetComponent<TeamMember>();
|
||||
animationManager = GetComponent<AnimationManager>();
|
||||
characterAttacks = GetComponent<CharacterAttacks>();
|
||||
health = GetComponent<HealthNew>();
|
||||
cashSystemAI = GetComponent<CashSystemAI>();
|
||||
|
||||
// Cache external singletons (lazy — may be null at Awake)
|
||||
cachedCamera = Camera.main;
|
||||
}
|
||||
|
||||
private void InitializeAI()
|
||||
{
|
||||
// Apply stats if available
|
||||
if (characterStats != null)
|
||||
{
|
||||
attackRange = characterStats.preferredCombatRange;
|
||||
attackCooldown = 1f / characterStats.attackFrequency;
|
||||
}
|
||||
|
||||
// Apply speed to NavMeshAgent
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.speed = speed;
|
||||
navAgent.stoppingDistance = stoppingDistance;
|
||||
|
||||
// CRITICAL: Warp agent onto NavMesh surface to prevent floating.
|
||||
// NavMeshAgent can only manage grounding when it is ON the NavMesh.
|
||||
// If the agent was spawned slightly off the baked mesh, it floats.
|
||||
WarpToNavMesh();
|
||||
}
|
||||
|
||||
lastAttackTime = -attackCooldown; // Allow immediate first attack
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warp the NavMeshAgent to the nearest valid NavMesh point.
|
||||
/// Prevents floating caused by spawning off-NavMesh.
|
||||
/// </summary>
|
||||
private void WarpToNavMesh()
|
||||
{
|
||||
if (navAgent == null) return;
|
||||
|
||||
NavMeshHit navHit;
|
||||
if (NavMesh.SamplePosition(transform.position, out navHit, 20f, NavMesh.AllAreas))
|
||||
{
|
||||
navAgent.Warp(navHit.position);
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name}: Warped to NavMesh at Y={navHit.position.y} (was Y={transform.position.y})");
|
||||
}
|
||||
else
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NO NavMesh found within 20m! Character will float. " +
|
||||
"Ensure NavMesh is baked for this scene.");
|
||||
}
|
||||
}
|
||||
|
||||
private void DetermineMode()
|
||||
{
|
||||
// Check if we're in Cash System mode
|
||||
if (SelectionOptions.Instance != null && SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
currentMode = AIMode.CashSystem;
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name} set to CashSystem mode");
|
||||
}
|
||||
else
|
||||
{
|
||||
currentMode = AIMode.Practice;
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name} set to Practice mode");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coroutine Decision Loop
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine-based decision loop. Runs at variable intervals based on
|
||||
/// distance from camera (AI LOD). Much cheaper than Update() polling.
|
||||
/// </summary>
|
||||
private IEnumerator AIDecisionLoop()
|
||||
{
|
||||
yield return new WaitForSeconds(Random.Range(0.1f, 0.5f)); // Stagger AI startup
|
||||
|
||||
while (enabled)
|
||||
{
|
||||
if (!IsGamePaused() && attack)
|
||||
{
|
||||
// Choose interval based on distance from camera (AI LOD)
|
||||
float interval = GetDecisionInterval();
|
||||
|
||||
if (currentMode == AIMode.CashSystem)
|
||||
{
|
||||
// CashSystem mode: CashSystemAI is the SOLE authority.
|
||||
// We only sync animation — no state machine, no movement commands.
|
||||
// CashSystemAI drives NavMeshAgent directly.
|
||||
|
||||
// (Nothing to do here — animation sync is in UpdateCashSystemBehavior
|
||||
// which is now ONLY called for combat that CashSystemAI delegates)
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePracticeBehavior();
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(interval);
|
||||
}
|
||||
else if (currentMode == AIMode.CashSystem)
|
||||
{
|
||||
// CRITICAL: In CashSystem mode, do NOT call StopMovement() when attack=false.
|
||||
// CashSystemAI manages NavMeshAgent independently of the attack flag.
|
||||
// The attack flag only gates combat actions inside CashSystemAI.IsCombatEnabled().
|
||||
// Collection, deposit, and patrol run regardless of the attack flag.
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Practice mode: Paused or attack disabled — stop moving, check again soon
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI LOD: slower decisions when far from camera
|
||||
/// </summary>
|
||||
private float GetDecisionInterval()
|
||||
{
|
||||
if (cachedCamera == null)
|
||||
{
|
||||
cachedCamera = Camera.main;
|
||||
if (cachedCamera == null) return decisionInterval;
|
||||
}
|
||||
|
||||
float distFromCamera = Vector3.Distance(transform.position, cachedCamera.transform.position);
|
||||
return distFromCamera > lodDistance ? farDecisionInterval : decisionInterval;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Practice Mode Behavior
|
||||
|
||||
private void UpdatePracticeBehavior()
|
||||
{
|
||||
// Find target if none
|
||||
if (currentTarget == null)
|
||||
{
|
||||
FindTarget();
|
||||
if (currentTarget == null) return;
|
||||
}
|
||||
|
||||
// Check if target is still valid
|
||||
HealthNew targetHealth = currentTarget.GetComponent<HealthNew>();
|
||||
if (targetHealth != null && targetHealth.currentHealth <= 0)
|
||||
{
|
||||
currentTarget = null;
|
||||
SetState(AIState.Idle);
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate distance
|
||||
distanceToTarget = CalculateDistanceToTarget();
|
||||
|
||||
|
||||
|
||||
// State machine
|
||||
switch (currentState)
|
||||
{
|
||||
case AIState.Idle:
|
||||
HandleIdleState();
|
||||
break;
|
||||
case AIState.Chasing:
|
||||
HandleChasingState();
|
||||
break;
|
||||
case AIState.Attacking:
|
||||
HandleAttackingState();
|
||||
break;
|
||||
case AIState.Retreating:
|
||||
HandleRetreatingState();
|
||||
break;
|
||||
case AIState.Blocking:
|
||||
HandleBlockingState();
|
||||
break;
|
||||
case AIState.Patrolling:
|
||||
HandlePatrollingState();
|
||||
break;
|
||||
}
|
||||
|
||||
// Always face target when in range
|
||||
if (distanceToTarget <= attackRange * 1.5f)
|
||||
{
|
||||
FaceTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleIdleState()
|
||||
{
|
||||
if (currentTarget != null && distanceToTarget <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No target in range — patrol instead of standing still
|
||||
SetState(AIState.Patrolling);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTA-style patrol: pick random nearby NavMesh points and walk between them.
|
||||
/// Immediately interrupts when a target enters chase range.
|
||||
/// </summary>
|
||||
private void HandlePatrollingState()
|
||||
{
|
||||
// Priority: if a target is in chase range, switch to chasing
|
||||
if (currentTarget != null && distanceToTarget <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
hasPatrolTarget = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find a target periodically
|
||||
if (currentTarget == null)
|
||||
{
|
||||
FindTarget();
|
||||
if (currentTarget != null && CalculateDistanceToTarget() <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
hasPatrolTarget = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait if pausing at patrol point
|
||||
if (Time.time < patrolPauseEndTime)
|
||||
{
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reached current patrol point
|
||||
if (hasPatrolTarget)
|
||||
{
|
||||
float distToPatrol = Vector3.Distance(transform.position, patrolTarget);
|
||||
if (distToPatrol < 2f)
|
||||
{
|
||||
// Arrived — pause briefly, then pick new point
|
||||
patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax);
|
||||
hasPatrolTarget = false;
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep moving toward patrol point
|
||||
MoveToTarget(patrolTarget);
|
||||
anim?.SetFloat("Speed", 1f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a new random NavMesh point
|
||||
Vector3 randomDir = Random.insideUnitSphere * patrolRadius;
|
||||
randomDir.y = 0;
|
||||
Vector3 randomPoint = transform.position + randomDir;
|
||||
|
||||
NavMeshHit hit;
|
||||
if (NavMesh.SamplePosition(randomPoint, out hit, patrolRadius, NavMesh.AllAreas))
|
||||
{
|
||||
patrolTarget = hit.position;
|
||||
hasPatrolTarget = true;
|
||||
MoveToTarget(patrolTarget);
|
||||
anim?.SetFloat("Speed", 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChasingState()
|
||||
{
|
||||
if (currentTarget == null) { SetState(AIState.Idle); return; }
|
||||
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
|
||||
if (distanceToTarget <= attackRange && CanAttack())
|
||||
{
|
||||
SetState(AIState.Attacking);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAttackingState()
|
||||
{
|
||||
// Stop moving when attacking
|
||||
StopMovement();
|
||||
|
||||
if (!isAttacking && CanAttack())
|
||||
{
|
||||
// Chance to retreat or block instead of attacking
|
||||
if (ShouldRetreat())
|
||||
{
|
||||
StartRetreat();
|
||||
return;
|
||||
}
|
||||
if (ShouldBlock())
|
||||
{
|
||||
StartBlock();
|
||||
return;
|
||||
}
|
||||
|
||||
PerformAttack();
|
||||
}
|
||||
|
||||
// Return to chasing if out of range
|
||||
if (distanceToTarget > attackRange * 1.2f)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRetreatingState()
|
||||
{
|
||||
if (Time.time >= retreatEndTime)
|
||||
{
|
||||
// Retreat done — go back to chasing
|
||||
SetState(AIState.Chasing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move away from target
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
Vector3 retreatPos = transform.position + retreatDirection * retreatDistance;
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(retreatPos);
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
|
||||
anim.SetFloat("Speed", spd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBlockingState()
|
||||
{
|
||||
StopMovement();
|
||||
|
||||
if (Time.time >= blockEndTime)
|
||||
{
|
||||
// Block done — counterattack or chase
|
||||
if (anim != null) anim.SetBool("isBlocking", false);
|
||||
SetState(distanceToTarget <= attackRange ? AIState.Attacking : AIState.Chasing);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRetreat()
|
||||
{
|
||||
if (health == null) return false;
|
||||
|
||||
// Higher chance to retreat when low health
|
||||
float healthPercent = (float)health.currentHealth / 100f;
|
||||
float adjustedChance = retreatChance;
|
||||
if (healthPercent * 100f < healthRetreatThreshold)
|
||||
{
|
||||
adjustedChance = retreatChance * 2.5f;
|
||||
}
|
||||
|
||||
return Random.value < adjustedChance;
|
||||
}
|
||||
|
||||
private bool ShouldBlock()
|
||||
{
|
||||
return Random.value < blockChance;
|
||||
}
|
||||
|
||||
private void StartRetreat()
|
||||
{
|
||||
SetState(AIState.Retreating);
|
||||
retreatEndTime = Time.time + retreatDuration;
|
||||
|
||||
// Calculate direction away from target
|
||||
if (currentTarget != null)
|
||||
{
|
||||
retreatDirection = (transform.position - currentTarget.transform.position).normalized;
|
||||
retreatDirection.y = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
retreatDirection = -transform.forward;
|
||||
}
|
||||
|
||||
// Play retreat animation
|
||||
PlayAnimation(AnimationNames.JumpBack);
|
||||
}
|
||||
|
||||
private void StartBlock()
|
||||
{
|
||||
SetState(AIState.Blocking);
|
||||
blockEndTime = Time.time + blockDuration;
|
||||
|
||||
if (anim != null) anim.SetBool("isBlocking", true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cash System Mode Behavior
|
||||
|
||||
/// <summary>
|
||||
/// CashSystem behavior: FULLY PASSIVE.
|
||||
/// CashSystemAI is the single authority — it drives NavMeshAgent directly,
|
||||
/// handles combat directly, and manages all decision-making.
|
||||
/// CharacterAIController does NOT touch NavMeshAgent in CashSystem mode.
|
||||
/// Animation is synced by CashSystemAI.SyncAnimation() directly.
|
||||
/// This method is kept as a no-op for backward compatibility.
|
||||
/// </summary>
|
||||
private void UpdateCashSystemBehavior()
|
||||
{
|
||||
// Intentionally empty — CashSystemAI owns everything in Cash System mode.
|
||||
// Animation sync is handled by CashSystemAI.SyncAnimation().
|
||||
// Combat is handled by CashSystemAI.PerformDirectAttack().
|
||||
// Navigation is handled by CashSystemAI.MoveToward().
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combat
|
||||
|
||||
private void FindTarget()
|
||||
{
|
||||
// Use static TeamMember registry if available (zero-allocation)
|
||||
if (teamMember != null)
|
||||
{
|
||||
float nearestDistance = float.MaxValue;
|
||||
|
||||
foreach (TeamMember other in TeamMember.AllMembers)
|
||||
{
|
||||
if (other == null || other == teamMember) continue;
|
||||
if (other.GetTeam() == teamMember.GetTeam()) continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, other.transform.position);
|
||||
if (distance < nearestDistance)
|
||||
{
|
||||
nearestDistance = distance;
|
||||
currentTarget = other.gameObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: find player by tag
|
||||
if (currentTarget == null)
|
||||
{
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player != null && player != gameObject)
|
||||
{
|
||||
currentTarget = player;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateDistanceToTarget()
|
||||
{
|
||||
if (currentTarget == null) return float.MaxValue;
|
||||
return Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
}
|
||||
|
||||
private bool CanAttack()
|
||||
{
|
||||
return Time.time >= lastAttackTime + attackCooldown + Random.Range(-randomAttackVariation, randomAttackVariation);
|
||||
}
|
||||
|
||||
private void PerformAttack()
|
||||
{
|
||||
if (isAttacking) return;
|
||||
|
||||
isAttacking = true;
|
||||
lastAttackTime = Time.time;
|
||||
|
||||
// Choose random attack type
|
||||
int attackType = Random.Range(0, 4);
|
||||
string animationName = "";
|
||||
|
||||
switch (attackType)
|
||||
{
|
||||
case 0:
|
||||
animationName = AnimationNames.Jab;
|
||||
break;
|
||||
case 1:
|
||||
animationName = AnimationNames.Right;
|
||||
break;
|
||||
case 2:
|
||||
animationName = AnimationNames.LowKick;
|
||||
break;
|
||||
case 3:
|
||||
animationName = AnimationNames.Haymaker;
|
||||
break;
|
||||
}
|
||||
|
||||
// Play animation
|
||||
PlayAnimation(animationName);
|
||||
|
||||
// Reset attack flag after animation
|
||||
StartCoroutine(ResetAttackFlag(0.8f));
|
||||
}
|
||||
|
||||
private IEnumerator ResetAttackFlag(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
isAttacking = false;
|
||||
}
|
||||
|
||||
private void PlayAnimation(string animationName)
|
||||
{
|
||||
if (animationManager != null)
|
||||
{
|
||||
animationManager.PlayAnimation(animationName);
|
||||
}
|
||||
else if (anim != null)
|
||||
{
|
||||
anim.Play(animationName);
|
||||
}
|
||||
}
|
||||
|
||||
private void FaceTarget()
|
||||
{
|
||||
if (currentTarget == null) return;
|
||||
|
||||
Vector3 direction = (currentTarget.transform.position - transform.position).normalized;
|
||||
direction.y = 0;
|
||||
|
||||
if (direction != Vector3.zero)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(direction);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Movement Helpers
|
||||
|
||||
private void MoveToTarget(Vector3 position)
|
||||
{
|
||||
if (navAgent == null || !navAgent.isActiveAndEnabled)
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NavMeshAgent null or disabled, cannot move!");
|
||||
return;
|
||||
}
|
||||
|
||||
// If agent fell off NavMesh, try to re-warp it
|
||||
if (!navAgent.isOnNavMesh)
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: Agent is OFF NavMesh! Attempting re-warp...");
|
||||
WarpToNavMesh();
|
||||
|
||||
// If still not on NavMesh after warp, give up this frame
|
||||
if (!navAgent.isOnNavMesh)
|
||||
{
|
||||
Debug.LogError($"[CharacterAIController] {gameObject.name}: Re-warp FAILED. No NavMesh at position {transform.position}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(position);
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
|
||||
anim.SetFloat("Speed", spd);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMovement()
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = true;
|
||||
navAgent.velocity = Vector3.zero;
|
||||
}
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetFloat("Speed", 0f);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Character Stability
|
||||
|
||||
private bool IsGamePaused()
|
||||
{
|
||||
// Lazy-cache PlayerManagerNew
|
||||
if (cachedPlayerManager == null)
|
||||
{
|
||||
cachedPlayerManager = FindFirstObjectByType<PlayerManagerNew>();
|
||||
}
|
||||
return cachedPlayerManager != null && cachedPlayerManager.pause;
|
||||
}
|
||||
|
||||
private void EnsureUprightCharacter()
|
||||
{
|
||||
if (IsCharacterTilted())
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.Euler(0, transform.rotation.eulerAngles.y, 0);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCharacterTilted()
|
||||
{
|
||||
return Mathf.Abs(transform.rotation.eulerAngles.x) > 5f ||
|
||||
Mathf.Abs(transform.rotation.eulerAngles.z) > 5f;
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
if (jump) return;
|
||||
|
||||
// CRITICAL FIX: When NavMeshAgent is active and ON the NavMesh,
|
||||
// it handles Y-positioning automatically. Applying manual gravity
|
||||
// CONFLICTS with NavMeshAgent and causes jittering/floating.
|
||||
// Only apply manual gravity as a FALLBACK when the agent is off-mesh.
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
// NavMeshAgent handles grounding — reset velocity and skip manual gravity
|
||||
gvelocity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
gvelocity += gravity * gravityScale * Time.deltaTime;
|
||||
|
||||
// Auto-configure ground layer mask if not set (exclude character layers)
|
||||
if (groundLayerMask.value == 0)
|
||||
{
|
||||
groundLayerMask = ~0; // Start with everything
|
||||
int playerLayer = LayerMask.NameToLayer("Player");
|
||||
int enemyLayer = LayerMask.NameToLayer("Enemy");
|
||||
int characterLayer = LayerMask.NameToLayer("Character");
|
||||
int ignoreRaycast = LayerMask.NameToLayer("Ignore Raycast");
|
||||
if (playerLayer >= 0) groundLayerMask &= ~(1 << playerLayer);
|
||||
if (enemyLayer >= 0) groundLayerMask &= ~(1 << enemyLayer);
|
||||
if (characterLayer >= 0) groundLayerMask &= ~(1 << characterLayer);
|
||||
if (ignoreRaycast >= 0) groundLayerMask &= ~(1 << ignoreRaycast);
|
||||
}
|
||||
|
||||
// Fallback gravity: only runs when NavMeshAgent is NOT handling grounding
|
||||
float groundY = 0f;
|
||||
RaycastHit hit;
|
||||
if (Physics.Raycast(transform.position + Vector3.up * 0.5f, Vector3.down, out hit, 50f, groundLayerMask))
|
||||
{
|
||||
groundY = hit.point.y;
|
||||
}
|
||||
|
||||
if (transform.position.y > groundY + 0.1f && !jump)
|
||||
{
|
||||
transform.position = new Vector3(
|
||||
transform.position.x,
|
||||
Mathf.Max(groundY + 0.05f, transform.position.y + gvelocity * Time.deltaTime),
|
||||
transform.position.z
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
gvelocity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Set the current target for this AI
|
||||
/// </summary>
|
||||
public void SetTarget(GameObject target)
|
||||
{
|
||||
currentTarget = target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current AI state
|
||||
/// </summary>
|
||||
public AIState GetState()
|
||||
{
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force the AI into a specific state
|
||||
/// </summary>
|
||||
public void SetState(AIState state)
|
||||
{
|
||||
if (currentState != state)
|
||||
{
|
||||
currentState = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the AI mode
|
||||
/// </summary>
|
||||
public void SetMode(AIMode mode)
|
||||
{
|
||||
currentMode = mode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger an attack (can be called by CashSystemAI)
|
||||
/// </summary>
|
||||
public void TriggerAttack()
|
||||
{
|
||||
if (CanAttack())
|
||||
{
|
||||
PerformAttack();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update NavMeshAgent speed at runtime (replaces EnemyNavigation.speed setter)
|
||||
/// </summary>
|
||||
public void SetSpeed(float newSpeed)
|
||||
{
|
||||
speed = newSpeed;
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current movement speed
|
||||
/// </summary>
|
||||
public float GetSpeed()
|
||||
{
|
||||
return speed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate to a world position (for CashSystemAI integration).
|
||||
/// Used for non-combat movement (collect, deposit, patrol).
|
||||
/// </summary>
|
||||
public void NavigateTo(Vector3 position)
|
||||
{
|
||||
cashSystemCombatMode = false; // Clear combat mode when navigating
|
||||
MoveToTarget(position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop all movement (for CashSystemAI integration)
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
StopMovement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enter combat mode for Cash System — enables the chase→attack→retreat→block
|
||||
/// state machine against the current target. Called by CashSystemAI.ExecuteAttack()
|
||||
/// and CashSystemAI.ExecuteDefend().
|
||||
/// </summary>
|
||||
public void SetCashSystemCombatTarget(Vector3 targetPosition)
|
||||
{
|
||||
cashSystemCombatMode = true;
|
||||
|
||||
// Ensure we start chasing if currently idle or patrolling
|
||||
if (currentState == AIState.Idle || currentState == AIState.Patrolling)
|
||||
SetState(AIState.Chasing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit combat mode (called when CashSystemAI switches to non-combat state).
|
||||
/// </summary>
|
||||
public void ClearCashSystemCombat()
|
||||
{
|
||||
cashSystemCombatMode = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
930
Assets/Scripts/Character/CharacterAIController.cs.bak
Normal file
930
Assets/Scripts/Character/CharacterAIController.cs.bak
Normal file
@@ -0,0 +1,930 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Unified AI Controller that handles both enemy and teammate AI.
|
||||
/// Replaces EnemyScript + EnemyNavigation with NavMeshAgent-based movement.
|
||||
/// Works with CashSystemAI for Cash System mode decisions.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class CharacterAIController : MonoBehaviour
|
||||
{
|
||||
#region Enums
|
||||
|
||||
public enum AIState
|
||||
{
|
||||
Idle,
|
||||
Chasing,
|
||||
Attacking,
|
||||
Retreating,
|
||||
Blocking,
|
||||
Dead,
|
||||
Patrolling // NEW: wander when no target in range
|
||||
}
|
||||
|
||||
public enum AIMode
|
||||
{
|
||||
Practice, // 1v1 mode - simple attack behavior
|
||||
CashSystem // Team mode - coordinate with CashSystemAI
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
[Header("AI Configuration")]
|
||||
[SerializeField] private CharacterStats characterStats;
|
||||
[SerializeField] private AIMode currentMode = AIMode.Practice;
|
||||
|
||||
[Header("Combat Permission")]
|
||||
[Tooltip("Combat permission flag — toggled by RoundsScript/PlayerManagerNew between rounds")]
|
||||
public bool attack = true;
|
||||
|
||||
[Header("Movement Settings")]
|
||||
[Tooltip("Base movement speed (replaces EnemyNavigation.speed)")]
|
||||
public float speed = 5f;
|
||||
[SerializeField] private float stoppingDistance = 1.5f;
|
||||
[SerializeField] private float rotationSpeed = 120f;
|
||||
|
||||
[Header("Combat Settings")]
|
||||
[SerializeField] private float attackRange = 2f;
|
||||
[SerializeField] private float chaseRange = 20f;
|
||||
[SerializeField] private float attackCooldown = 1.5f;
|
||||
[SerializeField] private float randomAttackVariation = 0.5f;
|
||||
|
||||
[Header("Retreat/Block Settings")]
|
||||
[SerializeField] private float retreatDistance = 4f;
|
||||
[SerializeField] private float retreatDuration = 1.5f;
|
||||
[SerializeField] private float blockDuration = 1.0f;
|
||||
[SerializeField] private float retreatChance = 0.15f;
|
||||
[SerializeField] private float blockChance = 0.20f;
|
||||
[SerializeField] private float healthRetreatThreshold = 30f;
|
||||
|
||||
[Header("Decision Loop")]
|
||||
[SerializeField] private float decisionInterval = 0.3f;
|
||||
[SerializeField] private float farDecisionInterval = 0.8f;
|
||||
[SerializeField] private float lodDistance = 25f;
|
||||
|
||||
[Header("Patrol Settings (Practice Mode)")]
|
||||
[SerializeField] private float patrolRadius = 10f;
|
||||
[SerializeField] private float patrolPauseMin = 1f;
|
||||
[SerializeField] private float patrolPauseMax = 2.5f;
|
||||
|
||||
[Header("Grounding")]
|
||||
[Tooltip("Layers to raycast against for ground detection. Must exclude character layers to prevent floating.")]
|
||||
[SerializeField] private LayerMask groundLayerMask = 0;
|
||||
|
||||
[Header("State")]
|
||||
[SerializeField] private AIState currentState = AIState.Idle;
|
||||
[SerializeField] private GameObject currentTarget;
|
||||
|
||||
// Cached components
|
||||
private Animator anim;
|
||||
private NavMeshAgent navAgent;
|
||||
private TeamMember teamMember;
|
||||
private AnimationManager animationManager;
|
||||
private CharacterAttacks characterAttacks;
|
||||
private HealthNew health;
|
||||
private CashSystemAI cashSystemAI;
|
||||
|
||||
// Cached external references
|
||||
private PlayerManagerNew cachedPlayerManager;
|
||||
private Camera cachedCamera;
|
||||
|
||||
// Internal state
|
||||
private float lastAttackTime;
|
||||
private float distanceToTarget;
|
||||
private bool isAttacking;
|
||||
private float retreatEndTime;
|
||||
private float blockEndTime;
|
||||
private Vector3 retreatDirection;
|
||||
private Coroutine decisionCoroutine;
|
||||
|
||||
// Patrol state
|
||||
private Vector3 patrolTarget;
|
||||
private bool hasPatrolTarget;
|
||||
private float patrolPauseEndTime;
|
||||
|
||||
// Gravity handling
|
||||
private float gvelocity;
|
||||
[SerializeField] private float gravity = -9.81f;
|
||||
[SerializeField] private float gravityScale = 0.75f;
|
||||
private bool jump;
|
||||
|
||||
// Scale protection: animation clips with baked scale curves from FBX import
|
||||
// can shrink the character model. Save and restore original scale each frame.
|
||||
private Vector3 originalScale;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
originalScale = transform.localScale;
|
||||
CacheComponents();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InitializeAI();
|
||||
DetermineMode();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Reset state when AI is enabled
|
||||
currentState = AIState.Idle;
|
||||
isAttacking = false;
|
||||
|
||||
// Re-warp to NavMesh when re-enabled (e.g., after control switch)
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled)
|
||||
{
|
||||
WarpToNavMesh();
|
||||
}
|
||||
|
||||
// Start the coroutine-based decision loop
|
||||
if (decisionCoroutine != null) StopCoroutine(decisionCoroutine);
|
||||
decisionCoroutine = StartCoroutine(AIDecisionLoop());
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (decisionCoroutine != null)
|
||||
{
|
||||
StopCoroutine(decisionCoroutine);
|
||||
decisionCoroutine = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FixedUpdate handles gravity and grounding every physics tick.
|
||||
/// This MUST run independently of the AI decision loop so characters
|
||||
/// stay grounded even before combat starts (attack == false).
|
||||
/// </summary>
|
||||
private void FixedUpdate()
|
||||
{
|
||||
ApplyGravity();
|
||||
EnsureUprightCharacter();
|
||||
|
||||
// Protect against animation clips overwriting scale
|
||||
if (transform.localScale != originalScale)
|
||||
{
|
||||
transform.localScale = originalScale;
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheComponents()
|
||||
{
|
||||
anim = GetComponent<Animator>();
|
||||
navAgent = GetComponent<NavMeshAgent>();
|
||||
teamMember = GetComponent<TeamMember>();
|
||||
animationManager = GetComponent<AnimationManager>();
|
||||
characterAttacks = GetComponent<CharacterAttacks>();
|
||||
health = GetComponent<HealthNew>();
|
||||
cashSystemAI = GetComponent<CashSystemAI>();
|
||||
|
||||
// Cache external singletons (lazy — may be null at Awake)
|
||||
cachedCamera = Camera.main;
|
||||
}
|
||||
|
||||
private void InitializeAI()
|
||||
{
|
||||
// Apply stats if available
|
||||
if (characterStats != null)
|
||||
{
|
||||
attackRange = characterStats.preferredCombatRange;
|
||||
attackCooldown = 1f / characterStats.attackFrequency;
|
||||
}
|
||||
|
||||
// Apply speed to NavMeshAgent
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.speed = speed;
|
||||
navAgent.stoppingDistance = stoppingDistance;
|
||||
|
||||
// CRITICAL: Warp agent onto NavMesh surface to prevent floating.
|
||||
// NavMeshAgent can only manage grounding when it is ON the NavMesh.
|
||||
// If the agent was spawned slightly off the baked mesh, it floats.
|
||||
WarpToNavMesh();
|
||||
}
|
||||
|
||||
lastAttackTime = -attackCooldown; // Allow immediate first attack
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warp the NavMeshAgent to the nearest valid NavMesh point.
|
||||
/// Prevents floating caused by spawning off-NavMesh.
|
||||
/// </summary>
|
||||
private void WarpToNavMesh()
|
||||
{
|
||||
if (navAgent == null) return;
|
||||
|
||||
NavMeshHit navHit;
|
||||
if (NavMesh.SamplePosition(transform.position, out navHit, 20f, NavMesh.AllAreas))
|
||||
{
|
||||
navAgent.Warp(navHit.position);
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name}: Warped to NavMesh at Y={navHit.position.y} (was Y={transform.position.y})");
|
||||
}
|
||||
else
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NO NavMesh found within 20m! Character will float. " +
|
||||
"Ensure NavMesh is baked for this scene.");
|
||||
}
|
||||
}
|
||||
|
||||
private void DetermineMode()
|
||||
{
|
||||
// Check if we're in Cash System mode
|
||||
if (SelectionOptions.Instance != null && SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
currentMode = AIMode.CashSystem;
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name} set to CashSystem mode");
|
||||
}
|
||||
else
|
||||
{
|
||||
currentMode = AIMode.Practice;
|
||||
DevLog.Log($"[CharacterAIController] {gameObject.name} set to Practice mode");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coroutine Decision Loop
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine-based decision loop. Runs at variable intervals based on
|
||||
/// distance from camera (AI LOD). Much cheaper than Update() polling.
|
||||
/// </summary>
|
||||
private IEnumerator AIDecisionLoop()
|
||||
{
|
||||
yield return new WaitForSeconds(Random.Range(0.1f, 0.5f)); // Stagger AI startup
|
||||
|
||||
while (enabled)
|
||||
{
|
||||
if (!IsGamePaused() && attack)
|
||||
{
|
||||
// Choose interval based on distance from camera (AI LOD)
|
||||
float interval = GetDecisionInterval();
|
||||
|
||||
if (currentMode == AIMode.CashSystem && cashSystemAI != null)
|
||||
{
|
||||
UpdateCashSystemBehavior();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePracticeBehavior();
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(interval);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Paused or attack disabled — stop moving, check again soon
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI LOD: slower decisions when far from camera
|
||||
/// </summary>
|
||||
private float GetDecisionInterval()
|
||||
{
|
||||
if (cachedCamera == null)
|
||||
{
|
||||
cachedCamera = Camera.main;
|
||||
if (cachedCamera == null) return decisionInterval;
|
||||
}
|
||||
|
||||
float distFromCamera = Vector3.Distance(transform.position, cachedCamera.transform.position);
|
||||
return distFromCamera > lodDistance ? farDecisionInterval : decisionInterval;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Practice Mode Behavior
|
||||
|
||||
private void UpdatePracticeBehavior()
|
||||
{
|
||||
// Find target if none
|
||||
if (currentTarget == null)
|
||||
{
|
||||
FindTarget();
|
||||
if (currentTarget == null) return;
|
||||
}
|
||||
|
||||
// Check if target is still valid
|
||||
HealthNew targetHealth = currentTarget.GetComponent<HealthNew>();
|
||||
if (targetHealth != null && targetHealth.currentHealth <= 0)
|
||||
{
|
||||
currentTarget = null;
|
||||
SetState(AIState.Idle);
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate distance
|
||||
distanceToTarget = CalculateDistanceToTarget();
|
||||
|
||||
|
||||
|
||||
// State machine
|
||||
switch (currentState)
|
||||
{
|
||||
case AIState.Idle:
|
||||
HandleIdleState();
|
||||
break;
|
||||
case AIState.Chasing:
|
||||
HandleChasingState();
|
||||
break;
|
||||
case AIState.Attacking:
|
||||
HandleAttackingState();
|
||||
break;
|
||||
case AIState.Retreating:
|
||||
HandleRetreatingState();
|
||||
break;
|
||||
case AIState.Blocking:
|
||||
HandleBlockingState();
|
||||
break;
|
||||
case AIState.Patrolling:
|
||||
HandlePatrollingState();
|
||||
break;
|
||||
}
|
||||
|
||||
// Always face target when in range
|
||||
if (distanceToTarget <= attackRange * 1.5f)
|
||||
{
|
||||
FaceTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleIdleState()
|
||||
{
|
||||
if (currentTarget != null && distanceToTarget <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No target in range — patrol instead of standing still
|
||||
SetState(AIState.Patrolling);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTA-style patrol: pick random nearby NavMesh points and walk between them.
|
||||
/// Immediately interrupts when a target enters chase range.
|
||||
/// </summary>
|
||||
private void HandlePatrollingState()
|
||||
{
|
||||
// Priority: if a target is in chase range, switch to chasing
|
||||
if (currentTarget != null && distanceToTarget <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
hasPatrolTarget = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find a target periodically
|
||||
if (currentTarget == null)
|
||||
{
|
||||
FindTarget();
|
||||
if (currentTarget != null && CalculateDistanceToTarget() <= chaseRange)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
hasPatrolTarget = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait if pausing at patrol point
|
||||
if (Time.time < patrolPauseEndTime)
|
||||
{
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reached current patrol point
|
||||
if (hasPatrolTarget)
|
||||
{
|
||||
float distToPatrol = Vector3.Distance(transform.position, patrolTarget);
|
||||
if (distToPatrol < 2f)
|
||||
{
|
||||
// Arrived — pause briefly, then pick new point
|
||||
patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax);
|
||||
hasPatrolTarget = false;
|
||||
StopMovement();
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep moving toward patrol point
|
||||
MoveToTarget(patrolTarget);
|
||||
anim?.SetFloat("Speed", 1f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a new random NavMesh point
|
||||
Vector3 randomDir = Random.insideUnitSphere * patrolRadius;
|
||||
randomDir.y = 0;
|
||||
Vector3 randomPoint = transform.position + randomDir;
|
||||
|
||||
NavMeshHit hit;
|
||||
if (NavMesh.SamplePosition(randomPoint, out hit, patrolRadius, NavMesh.AllAreas))
|
||||
{
|
||||
patrolTarget = hit.position;
|
||||
hasPatrolTarget = true;
|
||||
MoveToTarget(patrolTarget);
|
||||
anim?.SetFloat("Speed", 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChasingState()
|
||||
{
|
||||
if (currentTarget == null) { SetState(AIState.Idle); return; }
|
||||
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
|
||||
if (distanceToTarget <= attackRange && CanAttack())
|
||||
{
|
||||
SetState(AIState.Attacking);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAttackingState()
|
||||
{
|
||||
// Stop moving when attacking
|
||||
StopMovement();
|
||||
|
||||
if (!isAttacking && CanAttack())
|
||||
{
|
||||
// Chance to retreat or block instead of attacking
|
||||
if (ShouldRetreat())
|
||||
{
|
||||
StartRetreat();
|
||||
return;
|
||||
}
|
||||
if (ShouldBlock())
|
||||
{
|
||||
StartBlock();
|
||||
return;
|
||||
}
|
||||
|
||||
PerformAttack();
|
||||
}
|
||||
|
||||
// Return to chasing if out of range
|
||||
if (distanceToTarget > attackRange * 1.2f)
|
||||
{
|
||||
SetState(AIState.Chasing);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRetreatingState()
|
||||
{
|
||||
if (Time.time >= retreatEndTime)
|
||||
{
|
||||
// Retreat done — go back to chasing
|
||||
SetState(AIState.Chasing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move away from target
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
Vector3 retreatPos = transform.position + retreatDirection * retreatDistance;
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(retreatPos);
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
|
||||
anim.SetFloat("Speed", spd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBlockingState()
|
||||
{
|
||||
StopMovement();
|
||||
|
||||
if (Time.time >= blockEndTime)
|
||||
{
|
||||
// Block done — counterattack or chase
|
||||
if (anim != null) anim.SetBool("isBlocking", false);
|
||||
SetState(distanceToTarget <= attackRange ? AIState.Attacking : AIState.Chasing);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRetreat()
|
||||
{
|
||||
if (health == null) return false;
|
||||
|
||||
// Higher chance to retreat when low health
|
||||
float healthPercent = (float)health.currentHealth / 100f;
|
||||
float adjustedChance = retreatChance;
|
||||
if (healthPercent * 100f < healthRetreatThreshold)
|
||||
{
|
||||
adjustedChance = retreatChance * 2.5f;
|
||||
}
|
||||
|
||||
return Random.value < adjustedChance;
|
||||
}
|
||||
|
||||
private bool ShouldBlock()
|
||||
{
|
||||
return Random.value < blockChance;
|
||||
}
|
||||
|
||||
private void StartRetreat()
|
||||
{
|
||||
SetState(AIState.Retreating);
|
||||
retreatEndTime = Time.time + retreatDuration;
|
||||
|
||||
// Calculate direction away from target
|
||||
if (currentTarget != null)
|
||||
{
|
||||
retreatDirection = (transform.position - currentTarget.transform.position).normalized;
|
||||
retreatDirection.y = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
retreatDirection = -transform.forward;
|
||||
}
|
||||
|
||||
// Play retreat animation
|
||||
PlayAnimation(AnimationNames.JumpBack);
|
||||
}
|
||||
|
||||
private void StartBlock()
|
||||
{
|
||||
SetState(AIState.Blocking);
|
||||
blockEndTime = Time.time + blockDuration;
|
||||
|
||||
if (anim != null) anim.SetBool("isBlocking", true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cash System Mode Behavior
|
||||
|
||||
private void UpdateCashSystemBehavior()
|
||||
{
|
||||
// In Cash System mode, CashSystemAI tells us what to do
|
||||
// We handle animation sync and stability
|
||||
|
||||
if (cashSystemAI == null) return;
|
||||
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.hasPath)
|
||||
{
|
||||
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
|
||||
anim?.SetFloat("Speed", spd > 0.1f ? 1f : 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
anim?.SetFloat("Speed", 0f);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combat
|
||||
|
||||
private void FindTarget()
|
||||
{
|
||||
// Use static TeamMember registry if available (zero-allocation)
|
||||
if (teamMember != null)
|
||||
{
|
||||
float nearestDistance = float.MaxValue;
|
||||
|
||||
foreach (TeamMember other in TeamMember.AllMembers)
|
||||
{
|
||||
if (other == null || other == teamMember) continue;
|
||||
if (other.GetTeam() == teamMember.GetTeam()) continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, other.transform.position);
|
||||
if (distance < nearestDistance)
|
||||
{
|
||||
nearestDistance = distance;
|
||||
currentTarget = other.gameObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: find player by tag
|
||||
if (currentTarget == null)
|
||||
{
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player != null && player != gameObject)
|
||||
{
|
||||
currentTarget = player;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateDistanceToTarget()
|
||||
{
|
||||
if (currentTarget == null) return float.MaxValue;
|
||||
return Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
}
|
||||
|
||||
private bool CanAttack()
|
||||
{
|
||||
return Time.time >= lastAttackTime + attackCooldown + Random.Range(-randomAttackVariation, randomAttackVariation);
|
||||
}
|
||||
|
||||
private void PerformAttack()
|
||||
{
|
||||
if (isAttacking) return;
|
||||
|
||||
isAttacking = true;
|
||||
lastAttackTime = Time.time;
|
||||
|
||||
// Choose random attack type
|
||||
int attackType = Random.Range(0, 4);
|
||||
string animationName = "";
|
||||
|
||||
switch (attackType)
|
||||
{
|
||||
case 0:
|
||||
animationName = AnimationNames.Jab;
|
||||
break;
|
||||
case 1:
|
||||
animationName = AnimationNames.Right;
|
||||
break;
|
||||
case 2:
|
||||
animationName = AnimationNames.LowKick;
|
||||
break;
|
||||
case 3:
|
||||
animationName = AnimationNames.Haymaker;
|
||||
break;
|
||||
}
|
||||
|
||||
// Play animation
|
||||
PlayAnimation(animationName);
|
||||
|
||||
// Reset attack flag after animation
|
||||
StartCoroutine(ResetAttackFlag(0.8f));
|
||||
}
|
||||
|
||||
private IEnumerator ResetAttackFlag(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
isAttacking = false;
|
||||
}
|
||||
|
||||
private void PlayAnimation(string animationName)
|
||||
{
|
||||
if (animationManager != null)
|
||||
{
|
||||
animationManager.PlayAnimation(animationName);
|
||||
}
|
||||
else if (anim != null)
|
||||
{
|
||||
anim.Play(animationName);
|
||||
}
|
||||
}
|
||||
|
||||
private void FaceTarget()
|
||||
{
|
||||
if (currentTarget == null) return;
|
||||
|
||||
Vector3 direction = (currentTarget.transform.position - transform.position).normalized;
|
||||
direction.y = 0;
|
||||
|
||||
if (direction != Vector3.zero)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(direction);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Movement Helpers
|
||||
|
||||
private void MoveToTarget(Vector3 position)
|
||||
{
|
||||
if (navAgent == null || !navAgent.isActiveAndEnabled)
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NavMeshAgent null or disabled, cannot move!");
|
||||
return;
|
||||
}
|
||||
|
||||
// If agent fell off NavMesh, try to re-warp it
|
||||
if (!navAgent.isOnNavMesh)
|
||||
{
|
||||
DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: Agent is OFF NavMesh! Attempting re-warp...");
|
||||
WarpToNavMesh();
|
||||
|
||||
// If still not on NavMesh after warp, give up this frame
|
||||
if (!navAgent.isOnNavMesh)
|
||||
{
|
||||
Debug.LogError($"[CharacterAIController] {gameObject.name}: Re-warp FAILED. No NavMesh at position {transform.position}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(position);
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
|
||||
anim.SetFloat("Speed", spd);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMovement()
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = true;
|
||||
navAgent.velocity = Vector3.zero;
|
||||
}
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetFloat("Speed", 0f);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Character Stability
|
||||
|
||||
private bool IsGamePaused()
|
||||
{
|
||||
// Lazy-cache PlayerManagerNew
|
||||
if (cachedPlayerManager == null)
|
||||
{
|
||||
cachedPlayerManager = FindFirstObjectByType<PlayerManagerNew>();
|
||||
}
|
||||
return cachedPlayerManager != null && cachedPlayerManager.pause;
|
||||
}
|
||||
|
||||
private void EnsureUprightCharacter()
|
||||
{
|
||||
if (IsCharacterTilted())
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.Euler(0, transform.rotation.eulerAngles.y, 0);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCharacterTilted()
|
||||
{
|
||||
return Mathf.Abs(transform.rotation.eulerAngles.x) > 5f ||
|
||||
Mathf.Abs(transform.rotation.eulerAngles.z) > 5f;
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
if (jump) return;
|
||||
|
||||
// CRITICAL FIX: When NavMeshAgent is active and ON the NavMesh,
|
||||
// it handles Y-positioning automatically. Applying manual gravity
|
||||
// CONFLICTS with NavMeshAgent and causes jittering/floating.
|
||||
// Only apply manual gravity as a FALLBACK when the agent is off-mesh.
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
// NavMeshAgent handles grounding — reset velocity and skip manual gravity
|
||||
gvelocity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
gvelocity += gravity * gravityScale * Time.deltaTime;
|
||||
|
||||
// Auto-configure ground layer mask if not set (exclude character layers)
|
||||
if (groundLayerMask.value == 0)
|
||||
{
|
||||
groundLayerMask = ~0; // Start with everything
|
||||
int playerLayer = LayerMask.NameToLayer("Player");
|
||||
int enemyLayer = LayerMask.NameToLayer("Enemy");
|
||||
int characterLayer = LayerMask.NameToLayer("Character");
|
||||
int ignoreRaycast = LayerMask.NameToLayer("Ignore Raycast");
|
||||
if (playerLayer >= 0) groundLayerMask &= ~(1 << playerLayer);
|
||||
if (enemyLayer >= 0) groundLayerMask &= ~(1 << enemyLayer);
|
||||
if (characterLayer >= 0) groundLayerMask &= ~(1 << characterLayer);
|
||||
if (ignoreRaycast >= 0) groundLayerMask &= ~(1 << ignoreRaycast);
|
||||
}
|
||||
|
||||
// Fallback gravity: only runs when NavMeshAgent is NOT handling grounding
|
||||
float groundY = 0f;
|
||||
RaycastHit hit;
|
||||
if (Physics.Raycast(transform.position + Vector3.up * 0.5f, Vector3.down, out hit, 50f, groundLayerMask))
|
||||
{
|
||||
groundY = hit.point.y;
|
||||
}
|
||||
|
||||
if (transform.position.y > groundY + 0.1f && !jump)
|
||||
{
|
||||
transform.position = new Vector3(
|
||||
transform.position.x,
|
||||
Mathf.Max(groundY + 0.05f, transform.position.y + gvelocity * Time.deltaTime),
|
||||
transform.position.z
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
gvelocity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Set the current target for this AI
|
||||
/// </summary>
|
||||
public void SetTarget(GameObject target)
|
||||
{
|
||||
currentTarget = target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current AI state
|
||||
/// </summary>
|
||||
public AIState GetState()
|
||||
{
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force the AI into a specific state
|
||||
/// </summary>
|
||||
public void SetState(AIState state)
|
||||
{
|
||||
if (currentState != state)
|
||||
{
|
||||
currentState = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the AI mode
|
||||
/// </summary>
|
||||
public void SetMode(AIMode mode)
|
||||
{
|
||||
currentMode = mode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger an attack (can be called by CashSystemAI)
|
||||
/// </summary>
|
||||
public void TriggerAttack()
|
||||
{
|
||||
if (CanAttack())
|
||||
{
|
||||
PerformAttack();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update NavMeshAgent speed at runtime (replaces EnemyNavigation.speed setter)
|
||||
/// </summary>
|
||||
public void SetSpeed(float newSpeed)
|
||||
{
|
||||
speed = newSpeed;
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current movement speed
|
||||
/// </summary>
|
||||
public float GetSpeed()
|
||||
{
|
||||
return speed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate to a world position (for CashSystemAI integration)
|
||||
/// </summary>
|
||||
public void NavigateTo(Vector3 position)
|
||||
{
|
||||
MoveToTarget(position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop all movement (for CashSystemAI integration)
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
StopMovement();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff52d39875aa9c546ac93c6e92207ff8
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
2
Assets/Scripts/Character/CharacterAIController.cs.meta
Normal file
2
Assets/Scripts/Character/CharacterAIController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8bc310454534679acbc7032d061d408d
|
||||
356
Assets/Scripts/Character/CharacterInputHandler.cs
Normal file
356
Assets/Scripts/Character/CharacterInputHandler.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Modular input handler that replaces PlayerScript.
|
||||
/// Handles player input routing to character systems.
|
||||
/// Only active when character is player-controlled.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class CharacterInputHandler : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private CharacterStats characterStats;
|
||||
|
||||
// Cached components
|
||||
private Animator anim;
|
||||
private CharacterMovement characterMovement;
|
||||
private CharacterAttacks characterAttacks;
|
||||
private AnimationManager animationManager;
|
||||
private InputToAnimation inputToAnimation;
|
||||
private TeamMember teamMember;
|
||||
|
||||
[Header("State")]
|
||||
[Tooltip("Public input vector for other scripts to read")]
|
||||
public Vector2 input;
|
||||
|
||||
[Tooltip("Is the character currently blocking")]
|
||||
public bool block;
|
||||
|
||||
[Tooltip("Is the character currently dodging")]
|
||||
public bool dodge;
|
||||
|
||||
[Tooltip("Are attacks enabled (permission flag)")]
|
||||
public bool attackEnabled = true;
|
||||
|
||||
[Tooltip("Is the character in a helpless state")]
|
||||
public bool helpless;
|
||||
|
||||
[Tooltip("Is the character being held")]
|
||||
public bool held;
|
||||
|
||||
[Header("Camera")]
|
||||
[SerializeField] private Camera mainCamera;
|
||||
|
||||
[Header("Block/Dodge Settings")]
|
||||
[SerializeField] private float blockDuration = 0.5f;
|
||||
[SerializeField] private float dodgeDuration = 0.4f;
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
CacheComponents();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Initialize camera if not set
|
||||
if (mainCamera == null)
|
||||
{
|
||||
mainCamera = Camera.main;
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
block = false;
|
||||
dodge = false;
|
||||
helpless = false;
|
||||
held = false;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// When enabled (player takes control), ensure input starts fresh
|
||||
input = Vector2.zero;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// When disabled (AI takes control), clear input
|
||||
input = Vector2.zero;
|
||||
block = false;
|
||||
dodge = false;
|
||||
}
|
||||
|
||||
private void CacheComponents()
|
||||
{
|
||||
anim = GetComponent<Animator>();
|
||||
characterMovement = GetComponent<CharacterMovement>();
|
||||
characterAttacks = GetComponent<CharacterAttacks>();
|
||||
animationManager = GetComponent<AnimationManager>();
|
||||
inputToAnimation = GetComponent<InputToAnimation>();
|
||||
teamMember = GetComponent<TeamMember>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Input System Callbacks
|
||||
|
||||
/// <summary>
|
||||
/// Called by Input System for movement input (joystick/WASD)
|
||||
/// </summary>
|
||||
public void OnMove(InputAction.CallbackContext context)
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
input = context.ReadValue<Vector2>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mobile-specific movement handler
|
||||
/// </summary>
|
||||
public void OnMoveMobile()
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
// Block camera updates only during blocking or dodging
|
||||
if (block || dodge) return;
|
||||
|
||||
// Input is already filled by OnMove from the Input System
|
||||
// Handle any mobile-specific camera logic here if needed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by Input System for jump input
|
||||
/// </summary>
|
||||
public void OnJump(InputAction.CallbackContext context)
|
||||
{
|
||||
if (!enabled || !context.performed) return;
|
||||
if (helpless || held) return;
|
||||
|
||||
// Note: Jump is handled by CharacterMovement's gravity/grounding system
|
||||
// This callback can be used for additional jump logic if needed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle punch/jab attacks - routed through InputToAnimation
|
||||
/// </summary>
|
||||
public void OnPunch(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
public void OnKick(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
public void OnJab(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
public void OnHaymaker(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
// Mobile attack handlers
|
||||
public void OnPunchMobileNew(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
public void OnKickMobileNew(InputAction.CallbackContext context)
|
||||
{
|
||||
// No-op: Handled by InputToAnimation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle pause menu
|
||||
/// </summary>
|
||||
public void OnTogglePause(InputAction.CallbackContext context)
|
||||
{
|
||||
if (!context.performed) return;
|
||||
|
||||
PlayerManagerNew playerManager = FindFirstObjectByType<PlayerManagerNew>();
|
||||
if (playerManager != null)
|
||||
{
|
||||
// Toggle pause based on current state
|
||||
if (playerManager.pause)
|
||||
{
|
||||
playerManager.Resume();
|
||||
}
|
||||
else
|
||||
{
|
||||
playerManager.Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Block and Dodge
|
||||
|
||||
public void StartBlock()
|
||||
{
|
||||
if (block || dodge || helpless || held) return;
|
||||
|
||||
StartCoroutine(BlockCoroutine());
|
||||
}
|
||||
|
||||
public void StartDodge()
|
||||
{
|
||||
if (block || dodge || helpless || held) return;
|
||||
|
||||
StartCoroutine(DodgeCoroutine());
|
||||
}
|
||||
|
||||
private IEnumerator BlockCoroutine()
|
||||
{
|
||||
block = true;
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetBool("Block", true);
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(blockDuration);
|
||||
|
||||
block = false;
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetBool("Block", false);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator DodgeCoroutine()
|
||||
{
|
||||
dodge = true;
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
anim.SetTrigger("Dodge");
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(dodgeDuration);
|
||||
|
||||
dodge = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
|
||||
/// <summary>
|
||||
/// Set player input active/inactive
|
||||
/// </summary>
|
||||
public void SetPlayerInputActive(bool active)
|
||||
{
|
||||
PlayerInput playerInput = GetComponent<PlayerInput>();
|
||||
if (playerInput != null)
|
||||
{
|
||||
playerInput.enabled = active;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current input direction relative to camera
|
||||
/// </summary>
|
||||
public Vector3 GetDirectionRelativeToCamera()
|
||||
{
|
||||
if (mainCamera == null) return Vector3.zero;
|
||||
|
||||
Vector3 cameraForward = mainCamera.transform.forward;
|
||||
Vector3 cameraRight = mainCamera.transform.right;
|
||||
|
||||
cameraForward.y = 0;
|
||||
cameraRight.y = 0;
|
||||
cameraForward.Normalize();
|
||||
cameraRight.Normalize();
|
||||
|
||||
return (cameraForward * input.y + cameraRight * input.x).normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the camera reference
|
||||
/// </summary>
|
||||
public void SetMainCamera(Camera camera)
|
||||
{
|
||||
mainCamera = camera;
|
||||
|
||||
// Also update CharacterMovement if present
|
||||
if (characterMovement != null)
|
||||
{
|
||||
// CharacterMovement has its own mainCamera field
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Legacy Compatibility (No-ops for Input System routing)
|
||||
|
||||
// These methods exist for legacy compatibility with PlayerScript
|
||||
// All attack inputs are now routed through InputToAnimation
|
||||
|
||||
public void OnSpinningBackfist(InputAction.CallbackContext c) { }
|
||||
public void OnElbowSmash(InputAction.CallbackContext c) { }
|
||||
public void OnChargePunch(InputAction.CallbackContext c) { }
|
||||
public void OnAirPunch(InputAction.CallbackContext c) { }
|
||||
public void OnDropKick(InputAction.CallbackContext c) { }
|
||||
public void OnLowKick(InputAction.CallbackContext c) { }
|
||||
public void OnStrike(InputAction.CallbackContext c) { }
|
||||
public void OnLook(InputAction.CallbackContext c) { }
|
||||
public void OnSprint(InputAction.CallbackContext c) { }
|
||||
|
||||
// Vicious attacks
|
||||
public void OnChokeslam(InputAction.CallbackContext c) { }
|
||||
public void OnSuplex(InputAction.CallbackContext c) { }
|
||||
public void OnGiantSwing(InputAction.CallbackContext c) { }
|
||||
public void OnDiamondCrusher(InputAction.CallbackContext c) { }
|
||||
|
||||
// Special attacks
|
||||
public void OnRKO(InputAction.CallbackContext c) { }
|
||||
public void OnSpear(InputAction.CallbackContext c) { }
|
||||
public void OnRockBottom(InputAction.CallbackContext c) { }
|
||||
public void OnSumoSlap(InputAction.CallbackContext c) { }
|
||||
public void OnJavelinTackle(InputAction.CallbackContext c) { }
|
||||
public void OnF5(InputAction.CallbackContext c) { }
|
||||
public void OnSwantomBomb(InputAction.CallbackContext c) { }
|
||||
|
||||
// Mobile vicious/special
|
||||
public void OnViciousAttackMobile(InputAction.CallbackContext c) { }
|
||||
public void OnSpecialAttackMobile(InputAction.CallbackContext c) { }
|
||||
|
||||
// Punch variations
|
||||
public void OnRight(InputAction.CallbackContext c) { }
|
||||
public void OnLeftHook(InputAction.CallbackContext c) { }
|
||||
public void OnRightHook(InputAction.CallbackContext c) { }
|
||||
public void OnLeftElbow(InputAction.CallbackContext c) { }
|
||||
public void OnRightElbow(InputAction.CallbackContext c) { }
|
||||
public void OnRightBody(InputAction.CallbackContext c) { }
|
||||
public void OnPowerPunchLeft(InputAction.CallbackContext c) { }
|
||||
public void OnSupermanPunch(InputAction.CallbackContext c) { }
|
||||
|
||||
// Kick variations
|
||||
public void OnLegKickRight(InputAction.CallbackContext c) { }
|
||||
public void OnBodyKickRight(InputAction.CallbackContext c) { }
|
||||
public void OnKneeRight(InputAction.CallbackContext c) { }
|
||||
public void OnBackSideKick(InputAction.CallbackContext c) { }
|
||||
public void OnFrontKickRight(InputAction.CallbackContext c) { }
|
||||
public void OnLegPowerKickRight(InputAction.CallbackContext c) { }
|
||||
|
||||
// Jump variations
|
||||
public void OnJumpBack(InputAction.CallbackContext c) { }
|
||||
public void OnJumpLeft(InputAction.CallbackContext c) { }
|
||||
public void OnJumpRight(InputAction.CallbackContext c) { }
|
||||
|
||||
// Block variations
|
||||
public void OnBlockStepBack(InputAction.CallbackContext c) { }
|
||||
public void OnBlockBodyLeft(InputAction.CallbackContext c) { }
|
||||
public void OnBlockLeg(InputAction.CallbackContext c) { }
|
||||
|
||||
#endregion
|
||||
}
|
||||
2
Assets/Scripts/Character/CharacterInputHandler.cs.meta
Normal file
2
Assets/Scripts/Character/CharacterInputHandler.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 422ddb5842d4ace9d971e4fc5805f313
|
||||
69
Assets/Scripts/Character/CharacterStats.cs
Normal file
69
Assets/Scripts/Character/CharacterStats.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// ScriptableObject that holds character-specific data.
|
||||
/// This allows for data-driven character configuration without modifying prefabs.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "CharacterStats", menuName = "Deviant/Character Stats")]
|
||||
public class CharacterStats : ScriptableObject
|
||||
{
|
||||
[Header("Identity")]
|
||||
[Tooltip("Internal character name used for prefab loading")]
|
||||
public string characterName;
|
||||
|
||||
[Tooltip("Display name shown in UI")]
|
||||
public string displayName;
|
||||
|
||||
[Tooltip("Character description for selection screen")]
|
||||
[TextArea(2, 4)]
|
||||
public string description;
|
||||
|
||||
[Header("Combat Stats")]
|
||||
[Tooltip("Base health points")]
|
||||
public float baseHealth = 100f;
|
||||
|
||||
[Tooltip("Base movement speed")]
|
||||
public float baseSpeed = 5f;
|
||||
|
||||
[Tooltip("Base attack damage multiplier")]
|
||||
public float attackDamageMultiplier = 1f;
|
||||
|
||||
[Tooltip("Defense multiplier (reduces incoming damage)")]
|
||||
public float defenseMultiplier = 1f;
|
||||
|
||||
[Header("Movement Stats")]
|
||||
[Tooltip("Walk speed")]
|
||||
public float walkSpeed = 2f;
|
||||
|
||||
[Tooltip("Run speed")]
|
||||
public float runSpeed = 5f;
|
||||
|
||||
[Tooltip("Rotation speed")]
|
||||
public float rotationSpeed = 120f;
|
||||
|
||||
[Tooltip("Speed reduction when carrying cash (0.0 - 1.0)")]
|
||||
[Range(0f, 1f)]
|
||||
public float cashCarrySpeedPenalty = 0.3f;
|
||||
|
||||
[Header("AI Settings")]
|
||||
[Tooltip("Preferred combat range")]
|
||||
public float preferredCombatRange = 2f;
|
||||
|
||||
[Tooltip("Attack frequency (attacks per second)")]
|
||||
public float attackFrequency = 1f;
|
||||
|
||||
[Tooltip("Aggression level (0 = defensive, 1 = aggressive)")]
|
||||
[Range(0f, 1f)]
|
||||
public float aggressionLevel = 0.5f;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Character-specific animator controller (optional override)")]
|
||||
public RuntimeAnimatorController animatorController;
|
||||
|
||||
[Header("Visual")]
|
||||
[Tooltip("Character icon for UI")]
|
||||
public Sprite characterIcon;
|
||||
|
||||
[Tooltip("Character portrait for selection screen")]
|
||||
public Sprite characterPortrait;
|
||||
}
|
||||
2
Assets/Scripts/Character/CharacterStats.cs.meta
Normal file
2
Assets/Scripts/Character/CharacterStats.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 478887a527d47169290585924469822e
|
||||
8
Assets/Scripts/Character/Controllers.meta
Normal file
8
Assets/Scripts/Character/Controllers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 984a81b984fd88853a7493e73d6eaa53
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
276
Assets/Scripts/Character/Controllers/EnemyAIController.cs
Normal file
276
Assets/Scripts/Character/Controllers/EnemyAIController.cs
Normal file
@@ -0,0 +1,276 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// EnemyAIController - AI controller for ENEMY team characters.
|
||||
/// These AI characters fight AGAINST the player, targeting player team members.
|
||||
/// Used in Cash System mode for enemy team AI.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(TeamMember))]
|
||||
public class EnemyAIController : MonoBehaviour
|
||||
{
|
||||
[Header("AI State")]
|
||||
[SerializeField] private AIState currentState = AIState.Idle;
|
||||
|
||||
[Header("Detection Settings")]
|
||||
[SerializeField] private float detectionRange = 15f;
|
||||
[SerializeField] private float attackRange = 2f;
|
||||
[SerializeField] private float aggroRange = 10f;
|
||||
|
||||
[Header("Behavior Settings")]
|
||||
[SerializeField] private float decisionInterval = 0.5f;
|
||||
[SerializeField] private bool aggressive = true;
|
||||
|
||||
[Header("References")]
|
||||
private TeamMember teamMember;
|
||||
private NavMeshAgent navAgent;
|
||||
private Animator animator;
|
||||
private CharacterAttacks characterAttacks;
|
||||
|
||||
// Combat
|
||||
private TeamMember currentTarget;
|
||||
private float lastAttackTime;
|
||||
private float attackCooldown = 1.5f;
|
||||
|
||||
// Cash System integration
|
||||
private CashCarrier cashCarrier;
|
||||
private CashSystemAI cashAI;
|
||||
|
||||
public enum AIState
|
||||
{
|
||||
Idle,
|
||||
Patrol,
|
||||
ChaseEnemy,
|
||||
Attack,
|
||||
CollectCash,
|
||||
DepositCash,
|
||||
StealCash,
|
||||
Retreat
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
teamMember = GetComponent<TeamMember>();
|
||||
navAgent = GetComponent<NavMeshAgent>();
|
||||
animator = GetComponent<Animator>();
|
||||
characterAttacks = GetComponent<CharacterAttacks>();
|
||||
cashCarrier = GetComponent<CashCarrier>();
|
||||
cashAI = GetComponent<CashSystemAI>();
|
||||
|
||||
// Ensure this character is on enemy team
|
||||
if (teamMember != null)
|
||||
{
|
||||
teamMember.CurrentTeam = TeamMember.Team.EnemyTeam;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Debug.Log($"[EnemyAIController] Enabled on {gameObject.name}");
|
||||
StartCoroutine(AIDecisionLoop());
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Debug.Log($"[EnemyAIController] Disabled on {gameObject.name}");
|
||||
StopAllCoroutines();
|
||||
}
|
||||
|
||||
private IEnumerator AIDecisionLoop()
|
||||
{
|
||||
yield return new WaitForSeconds(0.5f); // Initial delay
|
||||
|
||||
while (enabled)
|
||||
{
|
||||
MakeDecision();
|
||||
yield return new WaitForSeconds(decisionInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private void MakeDecision()
|
||||
{
|
||||
// Lazy re-cache: CashSystemAI is added AFTER our Awake() by ConfigureCharacterControllers,
|
||||
// so the Awake-time GetComponent returns null. Re-check here once.
|
||||
if (cashAI == null) cashAI = GetComponent<CashSystemAI>();
|
||||
|
||||
// If CashSystemAI is attached and active, delegate ALL decisions to it
|
||||
if (cashAI != null && cashAI.enabled)
|
||||
{
|
||||
// CashSystemAI handles the high-level decision making via its own Update()
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard enemy AI decision making
|
||||
TeamMember nearestEnemy = teamMember.FindNearestEnemy(); // Player team
|
||||
|
||||
// Priority 1: Attack nearby player team members
|
||||
if (nearestEnemy != null)
|
||||
{
|
||||
float distanceToEnemy = Vector3.Distance(transform.position, nearestEnemy.transform.position);
|
||||
|
||||
if (distanceToEnemy <= attackRange)
|
||||
{
|
||||
SetState(AIState.Attack);
|
||||
currentTarget = nearestEnemy;
|
||||
PerformAttack();
|
||||
return;
|
||||
}
|
||||
else if (distanceToEnemy <= aggroRange && aggressive)
|
||||
{
|
||||
SetState(AIState.ChaseEnemy);
|
||||
currentTarget = nearestEnemy;
|
||||
MoveToTarget(nearestEnemy.transform.position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Patrol or idle
|
||||
if (aggressive && nearestEnemy != null)
|
||||
{
|
||||
float distanceToEnemy = Vector3.Distance(transform.position, nearestEnemy.transform.position);
|
||||
if (distanceToEnemy <= detectionRange)
|
||||
{
|
||||
SetState(AIState.ChaseEnemy);
|
||||
currentTarget = nearestEnemy;
|
||||
MoveToTarget(nearestEnemy.transform.position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Idle
|
||||
SetState(AIState.Idle);
|
||||
StopMovement();
|
||||
}
|
||||
|
||||
private void SetState(AIState newState)
|
||||
{
|
||||
if (currentState != newState)
|
||||
{
|
||||
currentState = newState;
|
||||
Debug.Log($"[EnemyAIController] {gameObject.name} -> {newState}");
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveToTarget(Vector3 position)
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(position);
|
||||
|
||||
// Update animator
|
||||
if (animator != null)
|
||||
{
|
||||
float speed = navAgent.velocity.magnitude / navAgent.speed;
|
||||
animator.SetFloat("Speed", speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMovement()
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = true;
|
||||
navAgent.velocity = Vector3.zero;
|
||||
}
|
||||
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetFloat("Speed", 0f);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformAttack()
|
||||
{
|
||||
if (Time.time - lastAttackTime < attackCooldown) return;
|
||||
|
||||
// Face the target
|
||||
if (currentTarget != null)
|
||||
{
|
||||
Vector3 direction = (currentTarget.transform.position - transform.position).normalized;
|
||||
direction.y = 0;
|
||||
if (direction.sqrMagnitude > 0.001f)
|
||||
{
|
||||
transform.rotation = Quaternion.LookRotation(direction);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform attack using CharacterAttacks if available
|
||||
if (characterAttacks != null)
|
||||
{
|
||||
// Choose a random attack
|
||||
string[] attacks = { AnimationNames.Jab, AnimationNames.LowKick, AnimationNames.Haymaker };
|
||||
string randomAttack = attacks[Random.Range(0, attacks.Length)];
|
||||
|
||||
characterAttacks.PunchAttacksDirectly(randomAttack);
|
||||
lastAttackTime = Time.time;
|
||||
}
|
||||
else if (animator != null)
|
||||
{
|
||||
// Fallback to direct animation trigger
|
||||
animator.SetTrigger("attack");
|
||||
lastAttackTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// When CashSystemAI is driving, don't interfere with NavMeshAgent
|
||||
if (cashAI != null && cashAI.enabled) return;
|
||||
|
||||
// Continuous state updates
|
||||
switch (currentState)
|
||||
{
|
||||
case AIState.Attack:
|
||||
if (currentTarget != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
if (distance > attackRange * 1.5f)
|
||||
{
|
||||
// Move closer
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
}
|
||||
else
|
||||
{
|
||||
StopMovement();
|
||||
PerformAttack();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case AIState.ChaseEnemy:
|
||||
if (currentTarget != null)
|
||||
{
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a specific target for this AI to attack
|
||||
/// </summary>
|
||||
public void SetTarget(TeamMember target)
|
||||
{
|
||||
currentTarget = target;
|
||||
if (target != null)
|
||||
{
|
||||
SetState(AIState.ChaseEnemy);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command this AI to move to a specific position
|
||||
/// </summary>
|
||||
public void MoveTo(Vector3 position)
|
||||
{
|
||||
MoveToTarget(position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current AI state
|
||||
/// </summary>
|
||||
public AIState GetCurrentState() => currentState;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30038cb6da0d4a575a78611ca2d78e3a
|
||||
271
Assets/Scripts/Character/Controllers/TeamAIController.cs
Normal file
271
Assets/Scripts/Character/Controllers/TeamAIController.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// TeamAIController - AI controller for FRIENDLY teammates on the player's team.
|
||||
/// These AI characters fight FOR the player, targeting enemy team members.
|
||||
/// Used in Cash System mode for AI teammates.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(TeamMember))]
|
||||
public class TeamAIController : MonoBehaviour
|
||||
{
|
||||
[Header("AI State")]
|
||||
[SerializeField] private AIState currentState = AIState.Idle;
|
||||
|
||||
[Header("Detection Settings")]
|
||||
[SerializeField] private float detectionRange = 15f;
|
||||
[SerializeField] private float attackRange = 2f;
|
||||
[SerializeField] private float followDistance = 5f;
|
||||
|
||||
[Header("Behavior Settings")]
|
||||
[SerializeField] private float decisionInterval = 0.5f;
|
||||
[SerializeField] private bool followPlayer = true;
|
||||
[SerializeField] private float maxDistanceFromPlayer = 20f;
|
||||
|
||||
[Header("References")]
|
||||
private TeamMember teamMember;
|
||||
private NavMeshAgent navAgent;
|
||||
private Animator animator;
|
||||
private CharacterAttacks characterAttacks;
|
||||
|
||||
// Combat
|
||||
private TeamMember currentTarget;
|
||||
private float lastAttackTime;
|
||||
private float attackCooldown = 1.5f;
|
||||
|
||||
// Cash System integration
|
||||
private CashCarrier cashCarrier;
|
||||
private CashSystemAI cashAI;
|
||||
|
||||
public enum AIState
|
||||
{
|
||||
Idle,
|
||||
FollowPlayer,
|
||||
ChaseEnemy,
|
||||
Attack,
|
||||
CollectCash,
|
||||
DepositCash,
|
||||
Retreat
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
teamMember = GetComponent<TeamMember>();
|
||||
navAgent = GetComponent<NavMeshAgent>();
|
||||
animator = GetComponent<Animator>();
|
||||
characterAttacks = GetComponent<CharacterAttacks>();
|
||||
cashCarrier = GetComponent<CashCarrier>();
|
||||
cashAI = GetComponent<CashSystemAI>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Debug.Log($"[TeamAIController] Enabled on {gameObject.name}");
|
||||
StartCoroutine(AIDecisionLoop());
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Debug.Log($"[TeamAIController] Disabled on {gameObject.name}");
|
||||
StopAllCoroutines();
|
||||
}
|
||||
|
||||
private IEnumerator AIDecisionLoop()
|
||||
{
|
||||
yield return new WaitForSeconds(0.5f); // Initial delay
|
||||
|
||||
while (enabled)
|
||||
{
|
||||
MakeDecision();
|
||||
yield return new WaitForSeconds(decisionInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private void MakeDecision()
|
||||
{
|
||||
// Lazy re-cache: CashSystemAI is added AFTER our Awake() by ConfigureCharacterControllers,
|
||||
// so the Awake-time GetComponent returns null. Re-check here once.
|
||||
if (cashAI == null) cashAI = GetComponent<CashSystemAI>();
|
||||
|
||||
// If CashSystemAI is attached and active, delegate ALL decisions to it
|
||||
if (cashAI != null && cashAI.enabled)
|
||||
{
|
||||
// CashSystemAI handles the high-level decision making via its own Update()
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard team AI decision making
|
||||
TeamMember nearestEnemy = teamMember.FindNearestEnemy();
|
||||
TeamMember playerControlled = TeamMember.GetPlayerControlledMember();
|
||||
|
||||
// Priority 1: Attack nearby enemies
|
||||
if (nearestEnemy != null)
|
||||
{
|
||||
float distanceToEnemy = Vector3.Distance(transform.position, nearestEnemy.transform.position);
|
||||
|
||||
if (distanceToEnemy <= attackRange)
|
||||
{
|
||||
SetState(AIState.Attack);
|
||||
currentTarget = nearestEnemy;
|
||||
PerformAttack();
|
||||
return;
|
||||
}
|
||||
else if (distanceToEnemy <= detectionRange)
|
||||
{
|
||||
SetState(AIState.ChaseEnemy);
|
||||
currentTarget = nearestEnemy;
|
||||
MoveToTarget(nearestEnemy.transform.position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Follow the player-controlled character
|
||||
if (followPlayer && playerControlled != null && playerControlled != teamMember)
|
||||
{
|
||||
float distanceToPlayer = Vector3.Distance(transform.position, playerControlled.transform.position);
|
||||
|
||||
if (distanceToPlayer > followDistance)
|
||||
{
|
||||
SetState(AIState.FollowPlayer);
|
||||
MoveToTarget(playerControlled.transform.position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Idle
|
||||
SetState(AIState.Idle);
|
||||
StopMovement();
|
||||
}
|
||||
|
||||
private void SetState(AIState newState)
|
||||
{
|
||||
if (currentState != newState)
|
||||
{
|
||||
currentState = newState;
|
||||
Debug.Log($"[TeamAIController] {gameObject.name} -> {newState}");
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveToTarget(Vector3 position)
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = false;
|
||||
navAgent.SetDestination(position);
|
||||
|
||||
// Update animator
|
||||
if (animator != null)
|
||||
{
|
||||
float speed = navAgent.velocity.magnitude / navAgent.speed;
|
||||
animator.SetFloat("Speed", speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMovement()
|
||||
{
|
||||
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
|
||||
{
|
||||
navAgent.isStopped = true;
|
||||
navAgent.velocity = Vector3.zero;
|
||||
}
|
||||
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetFloat("Speed", 0f);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformAttack()
|
||||
{
|
||||
if (Time.time - lastAttackTime < attackCooldown) return;
|
||||
|
||||
// Face the target
|
||||
if (currentTarget != null)
|
||||
{
|
||||
Vector3 direction = (currentTarget.transform.position - transform.position).normalized;
|
||||
direction.y = 0;
|
||||
if (direction.sqrMagnitude > 0.001f)
|
||||
{
|
||||
transform.rotation = Quaternion.LookRotation(direction);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform attack using CharacterAttacks if available
|
||||
if (characterAttacks != null)
|
||||
{
|
||||
// Choose a random attack
|
||||
string[] attacks = { AnimationNames.Jab, AnimationNames.LowKick, AnimationNames.Haymaker };
|
||||
string randomAttack = attacks[Random.Range(0, attacks.Length)];
|
||||
|
||||
characterAttacks.PunchAttacksDirectly(randomAttack);
|
||||
lastAttackTime = Time.time;
|
||||
}
|
||||
else if (animator != null)
|
||||
{
|
||||
// Fallback to direct animation trigger
|
||||
animator.SetTrigger("attack");
|
||||
lastAttackTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// When CashSystemAI is driving, don't interfere with NavMeshAgent
|
||||
if (cashAI != null && cashAI.enabled) return;
|
||||
|
||||
// Continuous state updates
|
||||
switch (currentState)
|
||||
{
|
||||
case AIState.Attack:
|
||||
if (currentTarget != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
if (distance > attackRange * 1.5f)
|
||||
{
|
||||
// Move closer
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
}
|
||||
else
|
||||
{
|
||||
StopMovement();
|
||||
PerformAttack();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case AIState.ChaseEnemy:
|
||||
if (currentTarget != null)
|
||||
{
|
||||
MoveToTarget(currentTarget.transform.position);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a specific target for this AI to attack
|
||||
/// </summary>
|
||||
public void SetTarget(TeamMember target)
|
||||
{
|
||||
currentTarget = target;
|
||||
if (target != null)
|
||||
{
|
||||
SetState(AIState.ChaseEnemy);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command this AI to move to a specific position
|
||||
/// </summary>
|
||||
public void MoveTo(Vector3 position)
|
||||
{
|
||||
MoveToTarget(position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current AI state
|
||||
/// </summary>
|
||||
public AIState GetCurrentState() => currentState;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c790a89885188fadd8e16afaef7274f4
|
||||
453
Assets/Scripts/Character/TeamControlManager.cs
Normal file
453
Assets/Scripts/Character/TeamControlManager.cs
Normal file
@@ -0,0 +1,453 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// TeamControlManager - Manages player control switching between team members.
|
||||
/// Inspired by FRAG Pro Shooter's instant character switching system.
|
||||
/// </summary>
|
||||
public class TeamControlManager : MonoBehaviour
|
||||
{
|
||||
public static TeamControlManager Instance { get; private set; }
|
||||
|
||||
[Header("Current Control")]
|
||||
[SerializeField] private GameObject currentControlledCharacter;
|
||||
[SerializeField] private int currentCharacterIndex = 0;
|
||||
|
||||
[Header("Team References")]
|
||||
[SerializeField] private List<GameObject> playerTeamCharacters = new List<GameObject>();
|
||||
|
||||
[Header("Camera Settings")]
|
||||
[SerializeField] private bool autoFollowCamera = true;
|
||||
|
||||
// Events
|
||||
public event System.Action<GameObject, GameObject> OnControlSwitched; // (old, new)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a character to the player team
|
||||
/// </summary>
|
||||
public void RegisterPlayerTeamCharacter(GameObject character, bool isPlayerControlled = false)
|
||||
{
|
||||
if (!playerTeamCharacters.Contains(character))
|
||||
{
|
||||
playerTeamCharacters.Add(character);
|
||||
DevLog.Log($"[TeamControlManager] Registered {character.name} to player team");
|
||||
|
||||
if (isPlayerControlled)
|
||||
{
|
||||
SetControlledCharacter(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a character from the team (e.g., on death)
|
||||
/// </summary>
|
||||
public void UnregisterCharacter(GameObject character)
|
||||
{
|
||||
if (playerTeamCharacters.Contains(character))
|
||||
{
|
||||
playerTeamCharacters.Remove(character);
|
||||
DevLog.Log($"[TeamControlManager] Unregistered {character.name}");
|
||||
|
||||
// If this was the controlled character, switch to another
|
||||
if (currentControlledCharacter == character)
|
||||
{
|
||||
SwitchToNextAvailableCharacter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set which character is player-controlled using the unified ControlType system
|
||||
/// </summary>
|
||||
public void SetControlledCharacter(GameObject newCharacter)
|
||||
{
|
||||
if (newCharacter == null || newCharacter == currentControlledCharacter) return;
|
||||
|
||||
GameObject oldCharacter = currentControlledCharacter;
|
||||
|
||||
// Get TeamMember components
|
||||
TeamMember oldMember = oldCharacter?.GetComponent<TeamMember>();
|
||||
TeamMember newMember = newCharacter.GetComponent<TeamMember>();
|
||||
|
||||
// Switch control using ControlType system (handles all component config!)
|
||||
if (oldMember != null)
|
||||
{
|
||||
// Old character becomes PlayerAI (still on player team, but AI controlled)
|
||||
oldMember.SetControlType(TeamMember.ControlType.PlayerAI);
|
||||
DevLog.Log($"[TeamControlManager] {oldCharacter.name} -> PlayerAI");
|
||||
}
|
||||
|
||||
if (newMember != null)
|
||||
{
|
||||
// New character becomes HumanControlled
|
||||
newMember.SetControlType(TeamMember.ControlType.HumanControlled);
|
||||
DevLog.Log($"[TeamControlManager] {newCharacter.name} -> HumanControlled");
|
||||
|
||||
// CRITICAL: Bind input actions for the new controlled character
|
||||
BindPlayerInputActions(newCharacter);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for characters without TeamMember (shouldn't happen)
|
||||
DevLog.LogWarning($"[TeamControlManager] {newCharacter.name} has no TeamMember component!");
|
||||
EnablePlayerControl(newCharacter);
|
||||
}
|
||||
|
||||
currentControlledCharacter = newCharacter;
|
||||
currentCharacterIndex = playerTeamCharacters.IndexOf(newCharacter);
|
||||
|
||||
// Update camera using CameraManager (preferred) or legacy UpdateCameraTarget
|
||||
if (CameraManager.Instance != null)
|
||||
{
|
||||
CameraManager.Instance.SetTarget(newMember);
|
||||
}
|
||||
else if (autoFollowCamera)
|
||||
{
|
||||
UpdateCameraTarget(newCharacter);
|
||||
}
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Switched control to {newCharacter.name}");
|
||||
OnControlSwitched?.Invoke(oldCharacter, newCharacter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to the next character in the team
|
||||
/// </summary>
|
||||
public void SwitchToNextCharacter()
|
||||
{
|
||||
if (playerTeamCharacters.Count <= 1) return;
|
||||
|
||||
int nextIndex = (currentCharacterIndex + 1) % playerTeamCharacters.Count;
|
||||
|
||||
// Find next alive character
|
||||
for (int i = 0; i < playerTeamCharacters.Count; i++)
|
||||
{
|
||||
int checkIndex = (currentCharacterIndex + 1 + i) % playerTeamCharacters.Count;
|
||||
GameObject candidate = playerTeamCharacters[checkIndex];
|
||||
|
||||
if (candidate != null && candidate.activeInHierarchy && IsCharacterAlive(candidate))
|
||||
{
|
||||
SetControlledCharacter(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DevLog.LogWarning("[TeamControlManager] No other alive characters to switch to!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to a specific character by index
|
||||
/// </summary>
|
||||
public void SwitchToCharacter(int index)
|
||||
{
|
||||
if (index >= 0 && index < playerTeamCharacters.Count)
|
||||
{
|
||||
GameObject character = playerTeamCharacters[index];
|
||||
if (character != null && character.activeInHierarchy && IsCharacterAlive(character))
|
||||
{
|
||||
SetControlledCharacter(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to next available character when current dies
|
||||
/// </summary>
|
||||
private void SwitchToNextAvailableCharacter()
|
||||
{
|
||||
foreach (var character in playerTeamCharacters)
|
||||
{
|
||||
if (character != null && character.activeInHierarchy && IsCharacterAlive(character))
|
||||
{
|
||||
SetControlledCharacter(character);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogError("[TeamControlManager] No available characters to control!");
|
||||
currentControlledCharacter = null;
|
||||
}
|
||||
|
||||
private bool IsCharacterAlive(GameObject character)
|
||||
{
|
||||
// Check if character has health and is alive
|
||||
HealthNew health = character.GetComponent<HealthNew>();
|
||||
if (health != null)
|
||||
{
|
||||
return !health.isDead;
|
||||
}
|
||||
return true; // Assume alive if no health component
|
||||
}
|
||||
|
||||
private void EnablePlayerControl(GameObject character)
|
||||
{
|
||||
// Update TeamMember
|
||||
TeamMember teamMember = character.GetComponent<TeamMember>();
|
||||
if (teamMember != null)
|
||||
{
|
||||
teamMember.IsPlayerControlled = true;
|
||||
}
|
||||
|
||||
// Enable PlayerScript
|
||||
PlayerScript playerScript = character.GetComponent<PlayerScript>();
|
||||
if (playerScript != null)
|
||||
{
|
||||
playerScript.enabled = true;
|
||||
playerScript.attack = true;
|
||||
}
|
||||
|
||||
// Enable PlayerInput
|
||||
PlayerInput playerInput = character.GetComponent<PlayerInput>();
|
||||
if (playerInput != null)
|
||||
{
|
||||
playerInput.enabled = true;
|
||||
try
|
||||
{
|
||||
playerInput.SwitchCurrentActionMap("Player Controls");
|
||||
}
|
||||
catch (System.Exception) { }
|
||||
}
|
||||
|
||||
// Update tag
|
||||
character.tag = "Player";
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Enabled player control on {character.name}");
|
||||
}
|
||||
|
||||
private void DisablePlayerControl(GameObject character)
|
||||
{
|
||||
// Update TeamMember
|
||||
TeamMember teamMember = character.GetComponent<TeamMember>();
|
||||
if (teamMember != null)
|
||||
{
|
||||
teamMember.IsPlayerControlled = false;
|
||||
}
|
||||
|
||||
// Disable PlayerScript (attacks, not movement animation)
|
||||
PlayerScript playerScript = character.GetComponent<PlayerScript>();
|
||||
if (playerScript != null)
|
||||
{
|
||||
playerScript.attack = false;
|
||||
// Keep enabled for animation but disable input
|
||||
}
|
||||
|
||||
// Disable PlayerInput
|
||||
PlayerInput playerInput = character.GetComponent<PlayerInput>();
|
||||
if (playerInput != null)
|
||||
{
|
||||
playerInput.enabled = false;
|
||||
}
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Disabled player control on {character.name}");
|
||||
}
|
||||
|
||||
private void EnableTeamAI(GameObject character)
|
||||
{
|
||||
TeamAIController aiController = character.GetComponent<TeamAIController>();
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.enabled = true;
|
||||
}
|
||||
|
||||
// Also enable CashSystemAI if present
|
||||
CashSystemAI cashAI = character.GetComponent<CashSystemAI>();
|
||||
if (cashAI != null)
|
||||
{
|
||||
cashAI.enabled = true;
|
||||
}
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Enabled AI on {character.name}");
|
||||
}
|
||||
|
||||
private void DisableTeamAI(GameObject character)
|
||||
{
|
||||
TeamAIController aiController = character.GetComponent<TeamAIController>();
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.enabled = false;
|
||||
}
|
||||
|
||||
// Also disable CashSystemAI if present (player makes own decisions)
|
||||
CashSystemAI cashAI = character.GetComponent<CashSystemAI>();
|
||||
if (cashAI != null)
|
||||
{
|
||||
cashAI.enabled = false;
|
||||
}
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Disabled AI on {character.name}");
|
||||
}
|
||||
|
||||
private void UpdateCameraTarget(GameObject character)
|
||||
{
|
||||
// Correct separation: Follow = root (orbit center), LookAt = head (aim point)
|
||||
Transform followTarget = character.transform;
|
||||
Transform lookAtTarget = FindCharacterHead(character);
|
||||
|
||||
// Update Cinemachine FreeLook with correct separation
|
||||
Cinemachine.CinemachineFreeLook[] freeLookCameras = FindObjectsOfType<Cinemachine.CinemachineFreeLook>();
|
||||
foreach (var cam in freeLookCameras)
|
||||
{
|
||||
if (cam.gameObject.activeInHierarchy)
|
||||
{
|
||||
cam.Follow = followTarget;
|
||||
cam.LookAt = lookAtTarget;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Cinemachine Virtual Cameras
|
||||
Cinemachine.CinemachineVirtualCamera[] virtualCameras = FindObjectsOfType<Cinemachine.CinemachineVirtualCamera>();
|
||||
foreach (var cam in virtualCameras)
|
||||
{
|
||||
if (cam.gameObject.activeInHierarchy)
|
||||
{
|
||||
cam.Follow = followTarget;
|
||||
cam.LookAt = lookAtTarget;
|
||||
}
|
||||
}
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Updated camera to follow {character.name} (Follow=root, LookAt=head)");
|
||||
}
|
||||
|
||||
private Transform FindCharacterHead(GameObject character)
|
||||
{
|
||||
string[] headNames = { "Head", "head", "Bip001 Head", "mixamorig:Head", "Bip01 Head" };
|
||||
|
||||
foreach (string headName in headNames)
|
||||
{
|
||||
Transform head = FindChildRecursive(character.transform, headName);
|
||||
if (head != null) return head;
|
||||
}
|
||||
|
||||
// Fallback to character root
|
||||
return character.transform;
|
||||
}
|
||||
|
||||
private Transform FindChildRecursive(Transform parent, string name)
|
||||
{
|
||||
foreach (Transform child in parent)
|
||||
{
|
||||
if (child.name == name) return child;
|
||||
Transform found = FindChildRecursive(child, name);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the currently controlled character
|
||||
/// </summary>
|
||||
public GameObject GetCurrentControlledCharacter() => currentControlledCharacter;
|
||||
|
||||
/// <summary>
|
||||
/// Get all player team characters
|
||||
/// </summary>
|
||||
public List<GameObject> GetPlayerTeamCharacters() => playerTeamCharacters;
|
||||
|
||||
/// <summary>
|
||||
/// Clear all registered characters
|
||||
/// </summary>
|
||||
public void ClearTeam()
|
||||
{
|
||||
playerTeamCharacters.Clear();
|
||||
currentControlledCharacter = null;
|
||||
currentCharacterIndex = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind input action callbacks to PlayerScript methods.
|
||||
/// This mirrors what Practice mode does in SelectionOptions.SetUpPlayerInputAI.
|
||||
/// </summary>
|
||||
private void BindPlayerInputActions(GameObject character)
|
||||
{
|
||||
PlayerInput playerInput = character.GetComponent<PlayerInput>();
|
||||
PlayerScript playerScript = character.GetComponent<PlayerScript>();
|
||||
|
||||
if (playerInput == null || playerScript == null)
|
||||
{
|
||||
DevLog.LogWarning($"[TeamControlManager] Cannot bind input - PlayerInput: {playerInput != null}, PlayerScript: {playerScript != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure PlayerInput is enabled
|
||||
playerInput.enabled = true;
|
||||
try { playerInput.SwitchCurrentActionMap("Player Controls"); } catch { }
|
||||
|
||||
var actionMap = playerInput.currentActionMap;
|
||||
if (actionMap == null)
|
||||
{
|
||||
Debug.LogError("[TeamControlManager] No current action map found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Bind the Move action!
|
||||
TryBindAction(actionMap, "Move", playerScript.OnMove);
|
||||
|
||||
// Bind attack actions
|
||||
TryBindAction(actionMap, "Jab", playerScript.OnJab);
|
||||
TryBindAction(actionMap, "Right", playerScript.OnRight);
|
||||
TryBindAction(actionMap, "LeftHook", playerScript.OnLeftHook);
|
||||
TryBindAction(actionMap, "RightHook", playerScript.OnRightHook);
|
||||
TryBindAction(actionMap, "LeftElbow", playerScript.OnLeftElbow);
|
||||
TryBindAction(actionMap, "RightElbow", playerScript.OnRightElbow);
|
||||
TryBindAction(actionMap, "RightBody", playerScript.OnRightBody);
|
||||
TryBindAction(actionMap, "PowerPunchLeft", playerScript.OnPowerPunchLeft);
|
||||
TryBindAction(actionMap, "SupermanPunch", playerScript.OnSupermanPunch);
|
||||
|
||||
// Kick actions
|
||||
TryBindAction(actionMap, "LegKickRight", playerScript.OnLegKickRight);
|
||||
TryBindAction(actionMap, "BodyKickRight", playerScript.OnBodyKickRight);
|
||||
TryBindAction(actionMap, "KneeRight", playerScript.OnKneeRight);
|
||||
TryBindAction(actionMap, "BackSideKick", playerScript.OnBackSideKick);
|
||||
TryBindAction(actionMap, "FrontKickRight", playerScript.OnFrontKickRight);
|
||||
TryBindAction(actionMap, "LegPowerKickRight", playerScript.OnLegPowerKickRight);
|
||||
|
||||
// Dodge actions
|
||||
TryBindAction(actionMap, "JumpBack", playerScript.OnJumpBack);
|
||||
TryBindAction(actionMap, "JumpLeft", playerScript.OnJumpLeft);
|
||||
TryBindAction(actionMap, "JumpRight", playerScript.OnJumpRight);
|
||||
|
||||
// Block actions
|
||||
TryBindAction(actionMap, "BlockStepBack", playerScript.OnBlockStepBack);
|
||||
TryBindAction(actionMap, "BlockBodyLeft", playerScript.OnBlockBodyLeft);
|
||||
TryBindAction(actionMap, "BlockLeg", playerScript.OnBlockLeg);
|
||||
|
||||
// Special attacks
|
||||
TryBindAction(actionMap, "Chokeslam", playerScript.OnChokeslam);
|
||||
TryBindAction(actionMap, "Suplex", playerScript.OnSuplex);
|
||||
TryBindAction(actionMap, "GiantSwing", playerScript.OnGiantSwing);
|
||||
TryBindAction(actionMap, "DiamondCrusher", playerScript.OnDiamondCrusher);
|
||||
TryBindAction(actionMap, "SumoSlap", playerScript.OnSumoSlap);
|
||||
TryBindAction(actionMap, "JavelinTackle", playerScript.OnJavelinTackle);
|
||||
TryBindAction(actionMap, "RKO", playerScript.OnRKO);
|
||||
TryBindAction(actionMap, "Spear", playerScript.OnSpear);
|
||||
TryBindAction(actionMap, "RockBottom", playerScript.OnRockBottom);
|
||||
TryBindAction(actionMap, "F5", playerScript.OnF5);
|
||||
|
||||
DevLog.Log($"[TeamControlManager] Input actions bound for {character.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to safely bind an action
|
||||
/// </summary>
|
||||
private void TryBindAction(InputActionMap actionMap, string actionName, System.Action<InputAction.CallbackContext> callback)
|
||||
{
|
||||
InputAction action = actionMap.FindAction(actionName);
|
||||
if (action != null)
|
||||
{
|
||||
action.performed += callback;
|
||||
if (actionName == "Move") action.canceled += callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Character/TeamControlManager.cs.meta
Normal file
2
Assets/Scripts/Character/TeamControlManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3860f569157a3b88cb69a771afb34716
|
||||
415
Assets/Scripts/Character/TeamMember.cs
Normal file
415
Assets/Scripts/Character/TeamMember.cs
Normal file
@@ -0,0 +1,415 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// TeamMember - Defines which team a character belongs to and who controls them.
|
||||
/// This is the foundation for the unified character control system.
|
||||
/// </summary>
|
||||
public class TeamMember : MonoBehaviour
|
||||
{
|
||||
#region Static Registry
|
||||
|
||||
/// <summary>
|
||||
/// Zero-allocation registry of all active TeamMembers.
|
||||
/// Use this instead of FindObjectsOfType.
|
||||
/// </summary>
|
||||
private static readonly List<TeamMember> _allMembers = new List<TeamMember>(16);
|
||||
public static IReadOnlyList<TeamMember> AllMembers => _allMembers;
|
||||
|
||||
#endregion
|
||||
/// <summary>
|
||||
/// Team affiliation enum
|
||||
/// </summary>
|
||||
public enum Team
|
||||
{
|
||||
PlayerTeam, // The player's team (human + AI teammates)
|
||||
EnemyTeam // The opposing team (all AI)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Control type - determines who controls this character
|
||||
/// </summary>
|
||||
public enum ControlType
|
||||
{
|
||||
HumanControlled, // Currently controlled by human player (reads input)
|
||||
PlayerAI, // AI teammate on player's team
|
||||
EnemyAI // AI enemy on opposing team
|
||||
}
|
||||
|
||||
[Header("Character Data")]
|
||||
[SerializeField] private CharacterStats _characterStats;
|
||||
|
||||
[Header("Team Settings")]
|
||||
[SerializeField] private Team _team = Team.PlayerTeam;
|
||||
|
||||
[Header("Control Settings")]
|
||||
[SerializeField] private ControlType _controlType = ControlType.EnemyAI;
|
||||
|
||||
/// <summary>
|
||||
/// The team this character belongs to
|
||||
/// </summary>
|
||||
public Team CurrentTeam
|
||||
{
|
||||
get => _team;
|
||||
set
|
||||
{
|
||||
_team = value;
|
||||
UpdateLayerAndTag();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control type for this character
|
||||
/// </summary>
|
||||
public ControlType CurrentControlType
|
||||
{
|
||||
get => _controlType;
|
||||
set => SetControlType(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this character is currently controlled by the human player
|
||||
/// (Backward compatible property - derives from ControlType)
|
||||
/// </summary>
|
||||
public bool IsPlayerControlled
|
||||
{
|
||||
get => _controlType == ControlType.HumanControlled;
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
SetControlType(ControlType.HumanControlled);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If turning off player control, make them AI based on team
|
||||
SetControlType(_team == Team.PlayerTeam ? ControlType.PlayerAI : ControlType.EnemyAI);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when control type changes (useful for camera, UI, input routing)
|
||||
/// </summary>
|
||||
public event System.Action<ControlType> OnControlTypeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when control status changes (backward compatible)
|
||||
/// </summary>
|
||||
public event System.Action<bool> OnControlChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when team changes
|
||||
/// </summary>
|
||||
public event System.Action<Team> OnTeamChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
UpdateLayerAndTag();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (!_allMembers.Contains(this))
|
||||
_allMembers.Add(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_allMembers.Remove(this);
|
||||
}
|
||||
|
||||
// Track if we've done initial configuration
|
||||
private bool _hasBeenConfigured = false;
|
||||
|
||||
/// <summary>
|
||||
/// Set the control type and configure all relevant components
|
||||
/// </summary>
|
||||
public void SetControlType(ControlType newType)
|
||||
{
|
||||
// Skip if already this type AND already configured
|
||||
if (_controlType == newType && _hasBeenConfigured)
|
||||
{
|
||||
Debug.Log($"[TeamMember] {gameObject.name}: Already configured as {newType}, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
ControlType oldType = _controlType;
|
||||
_controlType = newType;
|
||||
_hasBeenConfigured = true;
|
||||
|
||||
Debug.Log($"[TeamMember] {gameObject.name}: Control changed from {oldType} to {newType}");
|
||||
|
||||
// Configure components based on control type
|
||||
ConfigureControlComponents(newType);
|
||||
|
||||
// Update layer and tag
|
||||
UpdateLayerAndTag();
|
||||
|
||||
// Fire events
|
||||
OnControlTypeChanged?.Invoke(newType);
|
||||
OnControlChanged?.Invoke(newType == ControlType.HumanControlled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force reconfiguration even if type matches (useful for debugging)
|
||||
/// </summary>
|
||||
public void ForceReconfigure()
|
||||
{
|
||||
_hasBeenConfigured = false;
|
||||
SetControlType(_controlType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure all components based on control type
|
||||
/// </summary>
|
||||
private void ConfigureControlComponents(ControlType controlType)
|
||||
{
|
||||
// Get components
|
||||
PlayerScript playerScript = GetComponent<PlayerScript>();
|
||||
PlayerInput playerInput = GetComponent<PlayerInput>();
|
||||
CharacterInputHandler inputHandler = GetComponent<CharacterInputHandler>();
|
||||
CharacterAIController aiController = GetComponent<CharacterAIController>();
|
||||
CharacterMovement charMovement = GetComponent<CharacterMovement>();
|
||||
UnityEngine.AI.NavMeshAgent navAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
AudioListener audioListener = GetComponent<AudioListener>();
|
||||
|
||||
switch (controlType)
|
||||
{
|
||||
case ControlType.HumanControlled:
|
||||
// Enable player input components
|
||||
if (playerScript != null) playerScript.enabled = true;
|
||||
if (playerInput != null)
|
||||
{
|
||||
playerInput.enabled = true;
|
||||
try { playerInput.SwitchCurrentActionMap("Player Controls"); } catch { }
|
||||
}
|
||||
if (inputHandler != null) inputHandler.enabled = true;
|
||||
if (charMovement != null) charMovement.enabled = true;
|
||||
|
||||
// Disable AI components
|
||||
if (aiController != null) aiController.enabled = false;
|
||||
if (navAgent != null) navAgent.enabled = false;
|
||||
|
||||
// Enable audio listener only on player-controlled
|
||||
if (audioListener != null) audioListener.enabled = true;
|
||||
|
||||
// Disable embedded cameras (main camera will follow)
|
||||
DisableEmbeddedCameras();
|
||||
|
||||
Debug.Log($"[TeamMember] {gameObject.name}: Configured for HUMAN control");
|
||||
break;
|
||||
|
||||
case ControlType.PlayerAI:
|
||||
case ControlType.EnemyAI:
|
||||
// Disable player input components
|
||||
if (playerScript != null) playerScript.enabled = false;
|
||||
if (playerInput != null) playerInput.enabled = false;
|
||||
if (inputHandler != null) inputHandler.enabled = false;
|
||||
if (charMovement != null) charMovement.enabled = false; // AI uses NavMesh
|
||||
|
||||
// Enable AI components
|
||||
if (aiController != null) aiController.enabled = true;
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.enabled = true;
|
||||
|
||||
// CRITICAL: Warp agent to NavMesh IMMEDIATELY after enabling.
|
||||
// Without this, there is a 1-frame gap where the agent is enabled
|
||||
// but not on NavMesh, causing floating. CharacterAIController.Start()
|
||||
// also warps, but it runs on the NEXT frame — too late.
|
||||
UnityEngine.AI.NavMeshHit navHit;
|
||||
if (UnityEngine.AI.NavMesh.SamplePosition(transform.position, out navHit, 20f, UnityEngine.AI.NavMesh.AllAreas))
|
||||
{
|
||||
navAgent.Warp(navHit.position);
|
||||
Debug.Log($"[TeamMember] {gameObject.name}: Warped to NavMesh at Y={navHit.position.y}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[TeamMember] {gameObject.name}: No NavMesh found within 20m at {transform.position}!");
|
||||
}
|
||||
}
|
||||
|
||||
// Disable audio listener on AI
|
||||
if (audioListener != null) audioListener.enabled = false;
|
||||
|
||||
// Disable embedded cameras
|
||||
DisableEmbeddedCameras();
|
||||
|
||||
Debug.Log($"[TeamMember] {gameObject.name}: Configured for AI control ({controlType})");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable all cameras embedded in this character prefab
|
||||
/// </summary>
|
||||
private void DisableEmbeddedCameras()
|
||||
{
|
||||
Camera[] cameras = GetComponentsInChildren<Camera>(true);
|
||||
foreach (Camera cam in cameras)
|
||||
{
|
||||
cam.enabled = false;
|
||||
}
|
||||
|
||||
// Also disable Cinemachine components
|
||||
Cinemachine.CinemachineVirtualCameraBase[] vcams = GetComponentsInChildren<Cinemachine.CinemachineVirtualCameraBase>(true);
|
||||
foreach (var vcam in vcams)
|
||||
{
|
||||
vcam.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Check if another TeamMember is an enemy
|
||||
/// </summary>
|
||||
public bool IsEnemy(TeamMember other)
|
||||
{
|
||||
if (other == null) return false;
|
||||
return other.CurrentTeam != this.CurrentTeam;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if another TeamMember is an ally
|
||||
/// </summary>
|
||||
public bool IsAlly(TeamMember other)
|
||||
{
|
||||
if (other == null) return false;
|
||||
return other.CurrentTeam == this.CurrentTeam;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the layer and tag based on team
|
||||
/// </summary>
|
||||
private void UpdateLayerAndTag()
|
||||
{
|
||||
// Set appropriate tag (for compatibility with existing systems)
|
||||
if (_team == Team.PlayerTeam)
|
||||
{
|
||||
if (_controlType == ControlType.HumanControlled)
|
||||
{
|
||||
gameObject.tag = "Player";
|
||||
// Use Player One layer if available
|
||||
int playerLayer = LayerMask.NameToLayer("Player One");
|
||||
if (playerLayer >= 0) gameObject.layer = playerLayer;
|
||||
}
|
||||
else
|
||||
{
|
||||
// AI teammate - still on player team but different handling
|
||||
// Keep "Player" tag for combat targeting but mark as ally
|
||||
gameObject.tag = "Player";
|
||||
int playerLayer = LayerMask.NameToLayer("Player One");
|
||||
if (playerLayer >= 0) gameObject.layer = playerLayer;
|
||||
}
|
||||
}
|
||||
else // Enemy Team
|
||||
{
|
||||
gameObject.tag = "Enemy";
|
||||
int enemyLayer = LayerMask.NameToLayer("Player Two");
|
||||
if (enemyLayer >= 0) gameObject.layer = enemyLayer;
|
||||
}
|
||||
|
||||
OnTeamChanged?.Invoke(_team);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static helper to get all team members of a specific team
|
||||
/// </summary>
|
||||
public static TeamMember[] GetTeamMembers(Team team)
|
||||
{
|
||||
var result = new List<TeamMember>();
|
||||
for (int i = 0; i < _allMembers.Count; i++)
|
||||
{
|
||||
if (_allMembers[i] != null && _allMembers[i].CurrentTeam == team)
|
||||
result.Add(_allMembers[i]);
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the character stats for this character
|
||||
/// </summary>
|
||||
public CharacterStats GetCharacterStats()
|
||||
{
|
||||
return _characterStats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the character stats for this character
|
||||
/// </summary>
|
||||
public void SetCharacterStats(CharacterStats stats)
|
||||
{
|
||||
_characterStats = stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the team this character belongs to
|
||||
/// </summary>
|
||||
public Team GetTeam()
|
||||
{
|
||||
return _team;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static helper to get the currently player-controlled character
|
||||
/// </summary>
|
||||
public static TeamMember GetPlayerControlledMember()
|
||||
{
|
||||
for (int i = 0; i < _allMembers.Count; i++)
|
||||
{
|
||||
if (_allMembers[i] != null && _allMembers[i].IsPlayerControlled)
|
||||
return _allMembers[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the nearest enemy to this character
|
||||
/// </summary>
|
||||
public TeamMember FindNearestEnemy()
|
||||
{
|
||||
TeamMember nearest = null;
|
||||
float nearestDistance = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < _allMembers.Count; i++)
|
||||
{
|
||||
TeamMember other = _allMembers[i];
|
||||
if (other == null || other == this || other.CurrentTeam == _team) continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, other.transform.position);
|
||||
if (distance < nearestDistance)
|
||||
{
|
||||
nearestDistance = distance;
|
||||
nearest = other;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the nearest ally to this character
|
||||
/// </summary>
|
||||
public TeamMember FindNearestAlly()
|
||||
{
|
||||
TeamMember nearest = null;
|
||||
float nearestDistance = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < _allMembers.Count; i++)
|
||||
{
|
||||
TeamMember other = _allMembers[i];
|
||||
if (other == null || other == this || other.CurrentTeam != _team) continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, other.transform.position);
|
||||
if (distance < nearestDistance)
|
||||
{
|
||||
nearestDistance = distance;
|
||||
nearest = other;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Character/TeamMember.cs.meta
Normal file
2
Assets/Scripts/Character/TeamMember.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 942417f09d5edea1e8b55e9d3e00e891
|
||||
Reference in New Issue
Block a user