using UnityEngine; using UnityEngine.Events; using System.Collections; using System.Collections.Generic; /// /// CashoutStation — Deposit point for extraction vaults. /// /// FLOW: Carrier deposits vault → 30% instant payout → 45s defense timer for 70% → /// Enemy can contest (pause timer) or hack (10s to steal cashout). /// /// Replaces the role of TeamVault as the primary deposit zone in extraction mode. /// TeamVault still exists for backwards compatibility in the old bag-collection mode. /// /// Registers with EntityRegistry for zero-allocation queries. /// All notifications go through GameEvents. /// public class CashoutStation : MonoBehaviour { public enum StationState { Idle, CashingOut, Contested, Hacked, Completed } [Header("Team Settings")] [SerializeField] private string teamTag = "Player"; // "Player" or "Enemy" [SerializeField] private float storedCash = 0f; // Total team cash (all sources) [Header("Cashout Settings")] [SerializeField] private float instantPayoutPercent = 0.30f; // 30% on deposit [SerializeField] private float cashoutDuration = 45f; // 45s to cash out the remaining 70% [SerializeField] private float hackDuration = 10f; // 10s for enemy to steal a cashout [SerializeField] private float contestPauseDelay = 2f; // Seconds before contest pauses timer [Header("Interaction")] [SerializeField] private float depositRange = 4f; // Range to deposit vault [SerializeField] private float defenseRadius = 12f; // Area to defend during cashout [Header("Visual/Audio")] [SerializeField] private GameObject depositEffect; [SerializeField] private GameObject cashoutActiveEffect; [SerializeField] private GameObject contestedEffect; [SerializeField] private GameObject hackedEffect; [SerializeField] private ParticleSystem idleParticles; [SerializeField] private AudioClip depositSound; [SerializeField] private AudioClip cashoutCompleteSound; [SerializeField] private AudioClip hackedSound; [Header("Events (Inspector)")] public UnityEvent OnCashChanged; public UnityEvent OnCashoutStartedLocal; public UnityEvent OnCashoutProgressLocal; public UnityEvent OnCashoutCompletedLocal; public UnityEvent OnContestedLocal; public UnityEvent OnHackedLocal; // ─── Runtime ───────────────────────────────────────────── private StationState _state = StationState.Idle; private float _cashoutProgress; // 0-1 private float _cashoutRemainingValue; // The 70% being cashed out private float _hackProgress; // 0-1 private string _cashoutOwnerTeamTag; // Team that deposited the vault private Coroutine _cashoutCoroutine; private Coroutine _hackCoroutine; private readonly HashSet _contestingEnemies = new HashSet(); private ExtractionVault _currentVault; // Vault being cashed out // ─── Properties ────────────────────────────────────────── public float StoredCash => storedCash; public string TeamTag => teamTag; public StationState CurrentState => _state; public float CashoutProgress => _cashoutProgress; public float HackProgress => _hackProgress; public float CashoutRemainingValue => _cashoutRemainingValue; public bool IsCashingOut => _state == StationState.CashingOut || _state == StationState.Contested; public bool IsIdle => _state == StationState.Idle || _state == StationState.Completed; public float DefenseRadius => defenseRadius; // ─── Lifecycle ─────────────────────────────────────────── private void Start() { if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected) { enabled = false; return; } } private void OnEnable() { EntityRegistry.Register(this); } private void OnDisable() { EntityRegistry.Unregister(this); StopAllCoroutines(); } // ─── Trigger Detection ─────────────────────────────────── private void OnTriggerEnter(Collider other) { GameObject character = GetCharacterRoot(other); if (character == null) return; HealthNew hp = character.GetComponent(); if (hp != null && hp.isDead) return; bool isSameTeam = character.CompareTag(teamTag); if (isSameTeam) { // Same team: try to deposit a carried vault TryDepositVault(character); } else { // Enemy team: contest or hack an active cashout if (IsCashingOut) { _contestingEnemies.Add(character); if (_contestingEnemies.Count == 1) // First contester OnContestStart(character); } } } private void OnTriggerStay(Collider other) { // Check if enemy is still alive while contesting if (!IsCashingOut) return; GameObject character = GetCharacterRoot(other); if (character == null) return; if (character.CompareTag(teamTag)) return; // Same team, ignore if (_contestingEnemies.Contains(character)) { HealthNew hp = character.GetComponent(); if (hp != null && hp.isDead) { _contestingEnemies.Remove(character); if (_contestingEnemies.Count == 0) OnContestEnd(); } } } private void OnTriggerExit(Collider other) { GameObject character = GetCharacterRoot(other); if (character == null) return; if (_contestingEnemies.Remove(character)) { if (_contestingEnemies.Count == 0) OnContestEnd(); } } // ─── Deposit ───────────────────────────────────────────── /// Try to deposit a vault carried by this character. private void TryDepositVault(GameObject character) { // Find if this character is carrying an ExtractionVault var vaults = EntityRegistry.GetAll(); ExtractionVault carriedVault = null; for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == character) { carriedVault = vaults[i]; break; } } if (carriedVault == null) return; if (!IsIdle) return; // Can't deposit while another cashout is in progress DepositVault(carriedVault, character); } /// Deposit a vault and start the cashout timer. public void DepositVault(ExtractionVault vault, GameObject depositor) { if (vault == null || depositor == null) return; // Remove vault from world vault.OnDeposited(); _currentVault = vault; _cashoutOwnerTeamTag = depositor.tag; // Calculate payouts float remainingValue = vault.RemainingValue; // 70% of total vault value float instantPayout = remainingValue * instantPayoutPercent; // 30% of remaining = 21% of total // Actually the design says 30% instant on deposit, 70% over timer // So: instant = totalValue * 0.30, timer = totalValue * 0.70 // But openReward ($1000) was already awarded, so: // remainingValue = totalValue - openReward = $9,000 // instant = remainingValue * 0.30 = $2,700 // timer = remainingValue * 0.70 = $6,300 instantPayout = remainingValue * instantPayoutPercent; _cashoutRemainingValue = remainingValue - instantPayout; // Award instant payout storedCash += instantPayout; if (depositEffect != null) Instantiate(depositEffect, transform.position + Vector3.up, Quaternion.identity); PlaySound(depositSound); OnCashChanged?.Invoke(storedCash); GameEvents.FireScoreChanged(teamTag, storedCash); GameEvents.FireCashoutStarted(this, teamTag, instantPayout); GameEvents.FireKillFeedEntry(depositor.name, "DEPOSITED VAULT", $"+${instantPayout:F0} instant"); GameEvents.FirePopupMessage($"CASHOUT STARTED! +${instantPayout:F0}", 2f); DevLog.Log($"[CashoutStation] {depositor.name} deposited vault. Instant: ${instantPayout:F0}, Timer: ${_cashoutRemainingValue:F0} over {cashoutDuration}s"); // Start cashout timer _cashoutCoroutine = StartCoroutine(CashoutRoutine()); } private IEnumerator CashoutRoutine() { _state = StationState.CashingOut; _cashoutProgress = 0f; if (cashoutActiveEffect != null) cashoutActiveEffect.SetActive(true); float elapsed = 0f; float totalToCashout = _cashoutRemainingValue; while (elapsed < cashoutDuration) { // Pause while contested while (_state == StationState.Contested) { yield return null; } // Abort if hacked if (_state == StationState.Hacked) yield break; elapsed += Time.deltaTime; _cashoutProgress = Mathf.Clamp01(elapsed / cashoutDuration); // Drip cash over time float cashThisTick = (totalToCashout / cashoutDuration) * Time.deltaTime; storedCash += cashThisTick; // Fire progress events (throttled to ~4Hz) if (Time.frameCount % 15 == 0) { GameEvents.FireCashoutProgress(this, _cashoutProgress); OnCashoutProgressLocal?.Invoke(_cashoutProgress); GameEvents.FireScoreChanged(teamTag, storedCash); } yield return null; } // Cashout complete! CompleteCashout(); } private void CompleteCashout() { _state = StationState.Completed; _cashoutProgress = 1f; if (cashoutActiveEffect != null) cashoutActiveEffect.SetActive(false); PlaySound(cashoutCompleteSound); OnCashoutCompletedLocal?.Invoke(); GameEvents.FireCashoutCompleted(this, teamTag, _cashoutRemainingValue); GameEvents.FireScoreChanged(teamTag, storedCash); GameEvents.FireKillFeedEntry(teamTag, "CASHED OUT", $"${_cashoutRemainingValue:F0}!"); GameEvents.FirePopupMessage("CASHOUT COMPLETE!", 3f); DevLog.Log($"[CashoutStation] Cashout complete! Total team cash: ${storedCash:F0}"); // Reset to idle after a short delay StartCoroutine(ResetToIdleDelayed(3f)); } private IEnumerator ResetToIdleDelayed(float delay) { yield return new WaitForSeconds(delay); _state = StationState.Idle; _currentVault = null; _cashoutRemainingValue = 0f; _cashoutProgress = 0f; } // ─── Contest / Hack ────────────────────────────────────── private void OnContestStart(GameObject contester) { if (_state != StationState.CashingOut) return; _state = StationState.Contested; if (contestedEffect != null) contestedEffect.SetActive(true); OnContestedLocal?.Invoke(); GameEvents.FireCashoutContested(this, contester.tag); GameEvents.FirePopupMessage("CASHOUT CONTESTED!", 1.5f); DevLog.Log($"[CashoutStation] Cashout contested by {contester.name}!"); // Start hack timer — if enemy stays long enough, they steal the cashout _hackCoroutine = StartCoroutine(HackRoutine(contester)); } private void OnContestEnd() { if (_state != StationState.Contested) return; // Cancel hack if in progress if (_hackCoroutine != null) { StopCoroutine(_hackCoroutine); _hackCoroutine = null; } _hackProgress = 0f; if (contestedEffect != null) contestedEffect.SetActive(false); _state = StationState.CashingOut; DevLog.Log($"[CashoutStation] Contest ended, cashout resumed"); } private IEnumerator HackRoutine(GameObject hacker) { _hackProgress = 0f; float elapsed = 0f; while (elapsed < hackDuration) { if (hacker == null) { OnContestEnd(); yield break; } HealthNew hp = hacker.GetComponent(); if (hp != null && hp.isDead) { OnContestEnd(); yield break; } // Check hacker is still in range float dist = Vector3.Distance(hacker.transform.position, transform.position); if (dist > defenseRadius) { OnContestEnd(); yield break; } elapsed += Time.deltaTime; _hackProgress = Mathf.Clamp01(elapsed / hackDuration); yield return null; } // Hack successful! CompleteHack(hacker); } private void CompleteHack(GameObject hacker) { _state = StationState.Hacked; // Stop the cashout coroutine if (_cashoutCoroutine != null) { StopCoroutine(_cashoutCoroutine); _cashoutCoroutine = null; } // Calculate stolen amount (remaining uncashed value) float stolen = _cashoutRemainingValue * (1f - _cashoutProgress); // Remove from this team storedCash -= stolen; storedCash = Mathf.Max(storedCash, 0f); // Award to hacking team string hackerTeamTag = hacker.tag; AwardToTeam(hackerTeamTag, stolen); if (hackedEffect != null) hackedEffect.SetActive(true); if (cashoutActiveEffect != null) cashoutActiveEffect.SetActive(false); if (contestedEffect != null) contestedEffect.SetActive(false); PlaySound(hackedSound); OnHackedLocal?.Invoke(); GameEvents.FireCashoutHacked(this, hackerTeamTag, stolen); GameEvents.FireScoreChanged(teamTag, storedCash); GameEvents.FireScoreChanged(hackerTeamTag, GetTeamCash(hackerTeamTag)); GameEvents.FireKillFeedEntry(hacker.name, "HACKED CASHOUT", $"STOLE ${stolen:F0}!"); GameEvents.FirePopupMessage($"CASHOUT HACKED! ${stolen:F0} STOLEN!", 3f); DevLog.Log($"[CashoutStation] HACKED by {hacker.name}! ${stolen:F0} stolen to {hackerTeamTag}"); // Reset to idle StartCoroutine(ResetToIdleDelayed(3f)); } // ─── Direct Cash API ───────────────────────────────────── /// Add cash directly (kill rewards, vault open reward, etc.). public void AddDirectCash(float amount) { storedCash += amount; OnCashChanged?.Invoke(storedCash); GameEvents.FireScoreChanged(teamTag, storedCash); } /// Remove cash (team wipe penalty, etc.). public void RemoveCash(float amount) { storedCash -= amount; storedCash = Mathf.Max(storedCash, 0f); OnCashChanged?.Invoke(storedCash); GameEvents.FireScoreChanged(teamTag, storedCash); } // ─── Helpers ───────────────────────────────────────────── private void AwardToTeam(string targetTeamTag, float amount) { var stations = EntityRegistry.GetAll(); for (int i = 0; i < stations.Count; i++) { if (stations[i] != null && stations[i].teamTag == targetTeamTag) { stations[i].AddDirectCash(amount); return; } } // Fallback to TeamVault var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && vaults[i].TeamTag == targetTeamTag) { vaults[i].DepositCash(amount); return; } } } private float GetTeamCash(string targetTeamTag) { var stations = EntityRegistry.GetAll(); for (int i = 0; i < stations.Count; i++) { if (stations[i] != null && stations[i].teamTag == targetTeamTag) return stations[i].storedCash; } return 0f; } private GameObject GetCharacterRoot(Collider col) { TeamMember tm = col.GetComponentInParent(); if (tm != null) return tm.gameObject; CashCarrier carrier = col.GetComponentInParent(); if (carrier != null) return carrier.gameObject; return null; } private void PlaySound(AudioClip clip) { if (clip == null) return; Vector3 pos = Camera.main != null ? Camera.main.transform.position : transform.position; AudioSource.PlayClipAtPoint(clip, pos, 2f); } /// Get the opposing station via EntityRegistry. public CashoutStation GetOpposingStation() { var stations = EntityRegistry.GetAll(); for (int i = 0; i < stations.Count; i++) { if (stations[i] != null && stations[i].teamTag != this.teamTag) return stations[i]; } return null; } // ─── Gizmos ────────────────────────────────────────────── private void OnDrawGizmos() { Gizmos.color = teamTag == "Player" ? new Color(0, 0.5f, 1f, 0.3f) : new Color(1, 0.2f, 0.2f, 0.3f); Gizmos.DrawSphere(transform.position, 2f); Gizmos.color = new Color(1, 1, 0, 0.1f); Gizmos.DrawWireSphere(transform.position, defenseRadius); #if UNITY_EDITOR string stateLabel = _state switch { StationState.CashingOut => $"CASHING OUT {_cashoutProgress:P0}", StationState.Contested => $"CONTESTED! Hack:{_hackProgress:P0}", StationState.Hacked => "HACKED!", StationState.Completed => "COMPLETE", _ => "IDLE" }; UnityEditor.Handles.Label(transform.position + Vector3.up * 3f, $"{teamTag} CashoutStation\n${storedCash:F0}\n{stateLabel}"); #endif } }