Files

572 lines
20 KiB
C#

using UnityEngine;
using UnityEngine.AI;
using System.Collections;
/// <summary>
/// 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<ExtractionVault> for zero-allocation queries.
/// All notifications go through GameEvents.
/// </summary>
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<Collider>();
if (_collider != null) _collider.isTrigger = true;
_bobPhase = Random.Range(0f, Mathf.PI * 2f);
}
private void OnEnable()
{
EntityRegistry<ExtractionVault>.Register(this);
_spawnPosition = transform.position;
SetSealed();
}
private void OnDisable()
{
EntityRegistry<ExtractionVault>.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 ───────────────────────────────────
/// <summary>Reset to sealed state.</summary>
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();
}
/// <summary>
/// Start opening the vault. Takes openTime seconds.
/// Awards $1,000 instant reward to the opener's team on completion.
/// </summary>
public void StartOpening(GameObject opener)
{
if (_state != VaultState.Sealed) return;
if (opener == null) return;
if (_openCoroutine != null) StopCoroutine(_openCoroutine);
_openCoroutine = StartCoroutine(OpenRoutine(opener));
}
/// <summary>Cancel an in-progress open (e.g., opener moved away or got hit).</summary>
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<HealthNew>();
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<CashoutStation>.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<TeamVault>.GetAll();
for (int i = 0; i < vaults.Count; i++)
{
if (vaults[i] != null && vaults[i].TeamTag == teamTag)
{
vaults[i].DepositCash(openReward);
return;
}
}
}
/// <summary>Pick up the opened/dropped vault. Applies encumbrance to carrier.</summary>
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");
}
/// <summary>Drop the vault at a world position.</summary>
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}");
}
/// <summary>Called when vault is deposited at a CashoutStation. Removes from world.</summary>
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");
}
/// <summary>Remove from play (despawn timer expired, will respawn elsewhere).</summary>
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<NavMeshAgent>();
if (agent != null && agent.enabled)
{
_originalCarrierSpeed = agent.speed;
agent.speed *= carrySpeedMultiplier;
}
// Also set CashCarrier speed multiplier for animation sync
CashCarrier cashCarrier = carrier.GetComponent<CashCarrier>();
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<CharacterAIController>();
if (aiCtrl != null) aiCtrl.attack = false;
PlayerScript playerScript = carrier.GetComponent<PlayerScript>();
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<NavMeshAgent>();
if (agent != null && agent.enabled && _originalCarrierSpeed > 0)
{
agent.speed = _originalCarrierSpeed;
_originalCarrierSpeed = -1f;
}
// Re-enable attacks
if (disableAttackWhileCarrying)
{
CharacterAIController aiCtrl = _carrier.GetComponent<CharacterAIController>();
if (aiCtrl != null) aiCtrl.attack = true;
PlayerScript playerScript = _carrier.GetComponent<PlayerScript>();
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<HealthNew>();
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<PlayerScript>();
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<TeamMember>();
if (tm != null) return tm.gameObject;
// Fallback
CashCarrier carrier = col.GetComponentInParent<CashCarrier>();
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 ────────────────────────────────────────
/// <summary>Find the nearest available vault (opened or dropped, not carried).</summary>
public static ExtractionVault FindNearestAvailable(Vector3 position)
{
var vaults = EntityRegistry<ExtractionVault>.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;
}
/// <summary>Find the nearest sealed vault.</summary>
public static ExtractionVault FindNearestSealed(Vector3 position)
{
var vaults = EntityRegistry<ExtractionVault>.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;
}
/// <summary>Check if any vault is currently being carried by a specific team.</summary>
public static ExtractionVault FindCarriedByTeam(string teamTag)
{
var vaults = EntityRegistry<ExtractionVault>.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
}