521 lines
19 KiB
C#
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
|
|
}
|
|
}
|