using UnityEngine; using UnityEngine.AI; using System.Collections; /// /// ExtractionVault — The carryable vault objective for The Finals-style extraction mode. /// /// FLOW: Sealed → Opened ($1,000 instant) → Carried (encumbered) → Deposited at CashoutStation /// /// When a character interacts with a sealed vault, it "opens" over a short channel, /// awarding $1,000 immediately. The opener (or any teammate) can then pick it up. /// While carried: 40% speed reduction, no sprint, no attack. /// On carrier death: vault drops at death position (anyone can grab it). /// /// Registers with EntityRegistry for zero-allocation queries. /// All notifications go through GameEvents. /// public class ExtractionVault : MonoBehaviour { public enum VaultState { Sealed, Opening, Opened, Carried, Dropped, Deposited } [Header("Value")] [SerializeField] private float totalValue = 10000f; // Total vault worth [SerializeField] private float openReward = 1000f; // Instant reward on open [Header("Interaction")] [SerializeField] private float openTime = 3f; // Seconds to open the vault [SerializeField] private float interactRange = 3f; // Range to interact/open [SerializeField] private float pickupRange = 2.5f; // Range to pick up opened vault [SerializeField] private float dropDespawnTime = 60f; // Dropped vaults persist 60s before respawning elsewhere [Header("Encumbrance (while carried)")] [SerializeField] private float carrySpeedMultiplier = 0.60f; // 40% speed reduction [SerializeField] private bool disableAttackWhileCarrying = true; [SerializeField] private bool disableSprintWhileCarrying = true; [Header("Visual")] [SerializeField] private float rotationSpeed = 15f; [SerializeField] private float bobSpeed = 0.8f; [SerializeField] private float bobHeight = 0.15f; [SerializeField] private float floatOffset = 0.5f; [SerializeField] private GameObject sealedVisual; // Visual when sealed [SerializeField] private GameObject openedVisual; // Visual when opened/available [SerializeField] private ParticleSystem openEffect; [SerializeField] private ParticleSystem glowEffect; [SerializeField] private ParticleSystem dropImpactEffect; [Header("Audio")] [SerializeField] private AudioClip openSound; [SerializeField] private AudioClip pickupSound; [SerializeField] private AudioClip dropSound; [SerializeField] private float soundVolume = 2f; // ─── Runtime ───────────────────────────────────────────── private VaultState _state = VaultState.Sealed; private GameObject _carrier; // Who is carrying this vault private string _openerTeamTag; // Team that opened the vault private float _openProgress; // 0-1 opening progress private Coroutine _openCoroutine; private float _dropTime; private float _despawnTimer; private Vector3 _spawnPosition; private Collider _collider; private float _bobPhase; private float _originalCarrierSpeed = -1f; // ─── Properties ────────────────────────────────────────── public VaultState State => _state; public float TotalValue => totalValue; public float OpenReward => openReward; public float RemainingValue => totalValue - openReward; // What goes through the cashout station public bool IsSealed => _state == VaultState.Sealed; public bool IsOpened => _state == VaultState.Opened; public bool IsCarried => _state == VaultState.Carried; public bool IsDropped => _state == VaultState.Dropped; public bool IsAvailableForPickup => _state == VaultState.Opened || _state == VaultState.Dropped; public GameObject Carrier => _carrier; public string OpenerTeamTag => _openerTeamTag; public float OpenProgress => _openProgress; public Vector3 SpawnPosition => _spawnPosition; // ─── Lifecycle ─────────────────────────────────────────── private void Awake() { _collider = GetComponent(); if (_collider != null) _collider.isTrigger = true; _bobPhase = Random.Range(0f, Mathf.PI * 2f); } private void OnEnable() { EntityRegistry.Register(this); _spawnPosition = transform.position; SetSealed(); } private void OnDisable() { EntityRegistry.Unregister(this); // If we're carrying when disabled, release the carrier if (_carrier != null) ReleaseCarrier(); } private void Update() { switch (_state) { case VaultState.Sealed: case VaultState.Opened: AnimateIdle(); break; case VaultState.Carried: // Follow carrier if (_carrier != null) { transform.position = _carrier.transform.position + Vector3.up * 1.8f; transform.rotation = _carrier.transform.rotation; } else { // Carrier destroyed — drop Drop(transform.position); } break; case VaultState.Dropped: AnimateIdle(); _despawnTimer += Time.deltaTime; if (_despawnTimer >= dropDespawnTime) { // Respawn elsewhere via VaultSpawner Despawn(); } break; } } // ─── State Transitions ─────────────────────────────────── /// Reset to sealed state. public void SetSealed() { _state = VaultState.Sealed; _carrier = null; _openerTeamTag = null; _openProgress = 0f; _despawnTimer = 0f; if (_collider != null) _collider.enabled = true; gameObject.SetActive(true); if (sealedVisual != null) sealedVisual.SetActive(true); if (openedVisual != null) openedVisual.SetActive(false); if (glowEffect != null) glowEffect.Play(); } /// /// Start opening the vault. Takes openTime seconds. /// Awards $1,000 instant reward to the opener's team on completion. /// public void StartOpening(GameObject opener) { if (_state != VaultState.Sealed) return; if (opener == null) return; if (_openCoroutine != null) StopCoroutine(_openCoroutine); _openCoroutine = StartCoroutine(OpenRoutine(opener)); } /// Cancel an in-progress open (e.g., opener moved away or got hit). public void CancelOpening() { if (_openCoroutine != null) { StopCoroutine(_openCoroutine); _openCoroutine = null; } _openProgress = 0f; if (_state == VaultState.Opening) _state = VaultState.Sealed; } private IEnumerator OpenRoutine(GameObject opener) { _state = VaultState.Opening; _openProgress = 0f; float elapsed = 0f; while (elapsed < openTime) { // Abort if opener died or moved too far if (opener == null) { CancelOpening(); yield break; } HealthNew openerHP = opener.GetComponent(); if (openerHP != null && openerHP.isDead) { CancelOpening(); yield break; } float dist = Vector3.Distance(opener.transform.position, transform.position); if (dist > interactRange * 1.5f) { CancelOpening(); yield break; } elapsed += Time.deltaTime; _openProgress = Mathf.Clamp01(elapsed / openTime); yield return null; } // Successfully opened! CompleteOpen(opener); } private void CompleteOpen(GameObject opener) { _state = VaultState.Opened; _openProgress = 1f; _openerTeamTag = opener.tag; _openCoroutine = null; // Visual swap if (sealedVisual != null) sealedVisual.SetActive(false); if (openedVisual != null) openedVisual.SetActive(true); if (openEffect != null) openEffect.Play(); // Sound PlaySound(openSound); // Award $1,000 instant reward to the opener's team vault AwardOpenReward(opener); // Fire global event GameEvents.FireVaultOpened(this, opener); GameEvents.FireKillFeedEntry(opener.name, "CRACKED", $"VAULT (${openReward})"); GameEvents.FirePopupMessage($"VAULT OPENED! +${openReward}", 2f); DevLog.Log($"[ExtractionVault] {opener.name} opened vault at {transform.position}, awarded ${openReward} to {opener.tag}"); } private void AwardOpenReward(GameObject opener) { // Find the team's vault (TeamVault/CashoutStation) and deposit directly string teamTag = opener.tag; var stations = EntityRegistry.GetAll(); for (int i = 0; i < stations.Count; i++) { if (stations[i] != null && stations[i].TeamTag == teamTag) { stations[i].AddDirectCash(openReward); GameEvents.FireScoreChanged(teamTag, stations[i].StoredCash); return; } } // Fallback: use legacy TeamVault var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && vaults[i].TeamTag == teamTag) { vaults[i].DepositCash(openReward); return; } } } /// Pick up the opened/dropped vault. Applies encumbrance to carrier. public void PickUp(GameObject carrier) { if (!IsAvailableForPickup || carrier == null) return; _state = VaultState.Carried; _carrier = carrier; _despawnTimer = 0f; // Disable collider while carried if (_collider != null) _collider.enabled = false; if (glowEffect != null) glowEffect.Stop(); // Apply encumbrance ApplyEncumbrance(carrier); PlaySound(pickupSound); GameEvents.FireVaultPickedUp(this, carrier); GameEvents.FireKillFeedEntry(carrier.name, "GRABBED", "VAULT"); DevLog.Log($"[ExtractionVault] {carrier.name} picked up vault"); } /// Drop the vault at a world position. public void Drop(Vector3 dropPosition) { if (_carrier != null) ReleaseCarrier(); _state = VaultState.Dropped; _carrier = null; _dropTime = Time.time; _despawnTimer = 0f; // Snap to NavMesh NavMeshHit hit; if (NavMesh.SamplePosition(dropPosition, out hit, 10f, NavMesh.AllAreas)) dropPosition = hit.position + Vector3.up * 0.5f; else dropPosition += Vector3.up * 0.5f; transform.position = dropPosition; if (_collider != null) _collider.enabled = true; if (sealedVisual != null) sealedVisual.SetActive(false); if (openedVisual != null) openedVisual.SetActive(true); if (dropImpactEffect != null) { dropImpactEffect.transform.position = dropPosition; dropImpactEffect.Play(); } if (glowEffect != null) glowEffect.Play(); PlaySound(dropSound); GameEvents.FireVaultDropped(this, dropPosition); GameEvents.FireKillFeedEntry("", "VAULT DROPPED!", ""); DevLog.Log($"[ExtractionVault] Vault dropped at {dropPosition}"); } /// Called when vault is deposited at a CashoutStation. Removes from world. public void OnDeposited() { if (_carrier != null) ReleaseCarrier(); _state = VaultState.Deposited; if (glowEffect != null) glowEffect.Stop(); gameObject.SetActive(false); DevLog.Log($"[ExtractionVault] Vault deposited and removed from world"); } /// Remove from play (despawn timer expired, will respawn elsewhere). public void Despawn() { if (_carrier != null) ReleaseCarrier(); _state = VaultState.Sealed; if (glowEffect != null) glowEffect.Stop(); gameObject.SetActive(false); DevLog.Log($"[ExtractionVault] Vault despawned (timeout)"); } // ─── Encumbrance ───────────────────────────────────────── private void ApplyEncumbrance(GameObject carrier) { // Speed reduction via NavMeshAgent (AI) or CashCarrier speed mult NavMeshAgent agent = carrier.GetComponent(); if (agent != null && agent.enabled) { _originalCarrierSpeed = agent.speed; agent.speed *= carrySpeedMultiplier; } // Also set CashCarrier speed multiplier for animation sync CashCarrier cashCarrier = carrier.GetComponent(); if (cashCarrier != null) { // CashCarrier.SpeedMultiplier is read-only property driven by bags — // we handle vault encumbrance via a separate flag } // Disable attacks if (disableAttackWhileCarrying) { CharacterAIController aiCtrl = carrier.GetComponent(); if (aiCtrl != null) aiCtrl.attack = false; PlayerScript playerScript = carrier.GetComponent(); if (playerScript != null) playerScript.attack = false; } // Disable sprint via stamina drain (conceptual — sprint uses stamina) // The AI won't sprint because attack is disabled and speed is reduced } private void ReleaseCarrier() { if (_carrier == null) return; // Restore speed NavMeshAgent agent = _carrier.GetComponent(); if (agent != null && agent.enabled && _originalCarrierSpeed > 0) { agent.speed = _originalCarrierSpeed; _originalCarrierSpeed = -1f; } // Re-enable attacks if (disableAttackWhileCarrying) { CharacterAIController aiCtrl = _carrier.GetComponent(); if (aiCtrl != null) aiCtrl.attack = true; PlayerScript playerScript = _carrier.GetComponent(); if (playerScript != null) playerScript.attack = true; } _carrier = null; } // ─── Trigger Detection ─────────────────────────────────── private void OnTriggerEnter(Collider other) { if (_state == VaultState.Carried || _state == VaultState.Deposited) return; if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected) return; GameObject character = GetCharacterRoot(other); if (character == null) return; HealthNew hp = character.GetComponent(); if (hp != null && hp.isDead) return; if (_state == VaultState.Sealed) { // Character approach triggers opening (AI will call StartOpening explicitly) // For the player, auto-start opening on trigger enter PlayerScript ps = character.GetComponent(); if (ps != null && ps.enabled) { StartOpening(character); } } else if (IsAvailableForPickup) { // Anyone can pick up an opened/dropped vault PickUp(character); } } private void OnTriggerExit(Collider other) { if (_state != VaultState.Opening) return; GameObject character = GetCharacterRoot(other); if (character == null) return; // If the opener leaves range, cancel opening CancelOpening(); } private GameObject GetCharacterRoot(Collider col) { // Check for TeamMember on parent hierarchy TeamMember tm = col.GetComponentInParent(); if (tm != null) return tm.gameObject; // Fallback CashCarrier carrier = col.GetComponentInParent(); if (carrier != null) return carrier.gameObject; return null; } // ─── Animation ─────────────────────────────────────────── private void AnimateIdle() { // Rotate transform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime, Space.World); // Bob float y = _spawnPosition.y + floatOffset + Mathf.Sin((Time.time + _bobPhase) * bobSpeed) * bobHeight; transform.position = new Vector3(transform.position.x, y, transform.position.z); } // ─── Helpers ───────────────────────────────────────────── private void PlaySound(AudioClip clip) { if (clip == null) return; Vector3 pos = Camera.main != null ? Camera.main.transform.position : transform.position; AudioSource.PlayClipAtPoint(clip, pos, soundVolume); } // ─── Static Query ──────────────────────────────────────── /// Find the nearest available vault (opened or dropped, not carried). public static ExtractionVault FindNearestAvailable(Vector3 position) { var vaults = EntityRegistry.GetAll(); ExtractionVault best = null; float bestDist = float.MaxValue; for (int i = 0; i < vaults.Count; i++) { if (vaults[i] == null) continue; if (!vaults[i].IsAvailableForPickup) continue; float dist = Vector3.Distance(position, vaults[i].transform.position); if (dist < bestDist) { bestDist = dist; best = vaults[i]; } } return best; } /// Find the nearest sealed vault. public static ExtractionVault FindNearestSealed(Vector3 position) { var vaults = EntityRegistry.GetAll(); ExtractionVault best = null; float bestDist = float.MaxValue; for (int i = 0; i < vaults.Count; i++) { if (vaults[i] == null) continue; if (!vaults[i].IsSealed) continue; float dist = Vector3.Distance(position, vaults[i].transform.position); if (dist < bestDist) { bestDist = dist; best = vaults[i]; } } return best; } /// Check if any vault is currently being carried by a specific team. public static ExtractionVault FindCarriedByTeam(string teamTag) { var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] == null || !vaults[i].IsCarried) continue; if (vaults[i].Carrier != null && vaults[i].Carrier.CompareTag(teamTag)) return vaults[i]; } return null; } #if UNITY_EDITOR private void OnDrawGizmos() { Color c = _state switch { VaultState.Sealed => new Color(1f, 0.84f, 0f, 0.5f), // Gold VaultState.Opened => new Color(0f, 1f, 0f, 0.5f), // Green VaultState.Carried => new Color(0f, 0.5f, 1f, 0.5f), // Blue VaultState.Dropped => new Color(1f, 0.5f, 0f, 0.5f), // Orange _ => new Color(0.5f, 0.5f, 0.5f, 0.3f) }; Gizmos.color = c; Gizmos.DrawSphere(transform.position, 1f); Gizmos.DrawWireSphere(transform.position, interactRange); if (Application.isPlaying) { UnityEditor.Handles.Label(transform.position + Vector3.up * 2f, $"Vault [{_state}]\n${totalValue}\nOpen: {_openProgress:P0}" + (_carrier != null ? $"\nCarrier: {_carrier.name}" : "")); } } #endif }