Files

521 lines
19 KiB
C#

using UnityEngine;
using UnityEngine.Events;
using System.Collections;
using System.Collections.Generic;
/// <summary>
/// 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<CashoutStation> for zero-allocation queries.
/// All notifications go through GameEvents.
/// </summary>
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<float> OnCashChanged;
public UnityEvent OnCashoutStartedLocal;
public UnityEvent<float> 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<GameObject> _contestingEnemies = new HashSet<GameObject>();
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<CashoutStation>.Register(this);
}
private void OnDisable()
{
EntityRegistry<CashoutStation>.Unregister(this);
StopAllCoroutines();
}
// ─── Trigger Detection ───────────────────────────────────
private void OnTriggerEnter(Collider other)
{
GameObject character = GetCharacterRoot(other);
if (character == null) return;
HealthNew hp = character.GetComponent<HealthNew>();
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<HealthNew>();
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 ─────────────────────────────────────────────
/// <summary>Try to deposit a vault carried by this character.</summary>
private void TryDepositVault(GameObject character)
{
// Find if this character is carrying an ExtractionVault
var vaults = EntityRegistry<ExtractionVault>.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);
}
/// <summary>Deposit a vault and start the cashout timer.</summary>
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<HealthNew>();
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 ─────────────────────────────────────
/// <summary>Add cash directly (kill rewards, vault open reward, etc.).</summary>
public void AddDirectCash(float amount)
{
storedCash += amount;
OnCashChanged?.Invoke(storedCash);
GameEvents.FireScoreChanged(teamTag, storedCash);
}
/// <summary>Remove cash (team wipe penalty, etc.).</summary>
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<CashoutStation>.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<TeamVault>.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<CashoutStation>.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<TeamMember>();
if (tm != null) return tm.gameObject;
CashCarrier carrier = col.GetComponentInParent<CashCarrier>();
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);
}
/// <summary>Get the opposing station via EntityRegistry.</summary>
public CashoutStation GetOpposingStation()
{
var stations = EntityRegistry<CashoutStation>.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
}
}