572 lines
20 KiB
C#
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
|
|
}
|