chunk 1: core gameplay scripts scenes runtime assets

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

View File

@@ -0,0 +1,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
}

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ff52d39875aa9c546ac93c6e92207ff8
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8bc310454534679acbc7032d061d408d

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 422ddb5842d4ace9d971e4fc5805f313

View 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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 478887a527d47169290585924469822e

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 984a81b984fd88853a7493e73d6eaa53
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30038cb6da0d4a575a78611ca2d78e3a

View 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;
}

View File

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

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3860f569157a3b88cb69a771afb34716

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 942417f09d5edea1e8b55e9d3e00e891