chunk 1: core gameplay scripts scenes runtime assets
This commit is contained in:
415
Assets/Scripts/CashSystem/CashBag.cs
Normal file
415
Assets/Scripts/CashSystem/CashBag.cs
Normal file
@@ -0,0 +1,415 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// CashBag — Collectible cash bag for Cash System mode.
|
||||
/// Neutral gold color when spawned, takes team color on pickup.
|
||||
/// Tracks state: Available → Carried → Dropped → Available/Destroyed.
|
||||
/// Registers with EntityRegistry for zero-allocation queries.
|
||||
/// Fires GameEvents instead of using FindObjectOfType.
|
||||
/// The vault (TeamVault) is the "CashBox" where bags are deposited.
|
||||
/// </summary>
|
||||
public class CashBag : MonoBehaviour
|
||||
{
|
||||
public enum BagState { Available, Carried, Dropped }
|
||||
|
||||
[Header("Cash Settings")]
|
||||
[SerializeField] private float cashValue = 100f;
|
||||
[SerializeField] private float hotDropBonusPercent = 10f; // +10% if picked during hot drop window
|
||||
|
||||
[Header("Visual Feedback")]
|
||||
[SerializeField] private float rotationSpeed = 30f;
|
||||
[SerializeField] private float bobSpeed = 1f;
|
||||
[SerializeField] private float bobHeight = 0.2f;
|
||||
[SerializeField] private float floatOffset = 0.3f;
|
||||
[Header("Scale")]
|
||||
[SerializeField] private Vector3 targetScale = new Vector3(0.35f, 0.35f, 0.35f);
|
||||
|
||||
[Header("Drop Settings")]
|
||||
[SerializeField] private float hotDropWindow = 3f; // Seconds after drop where pickup gives bonus
|
||||
[SerializeField] private float dropDespawnTime = 30f; // Despawn dropped bag after this time
|
||||
[SerializeField] private float pickupGracePeriod = 1.5f; // Seconds after spawn/enable before pickup allowed
|
||||
|
||||
[Header("Visual References")]
|
||||
[SerializeField] private MeshRenderer meshRenderer;
|
||||
[SerializeField] private ParticleSystem idleParticles;
|
||||
[SerializeField] private ParticleSystem pickupBurstEffect;
|
||||
[SerializeField] private ParticleSystem dropImpactEffect;
|
||||
[SerializeField] private GameObject glowObject; // Point light or glow sprite
|
||||
[SerializeField] private GameObject worldIndicator; // "!" icon for dropped bags
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color neutralColor = new Color(1f, 0.84f, 0f); // Gold
|
||||
[SerializeField] private Color playerTeamColor = new Color(0.13f, 0.59f, 0.95f);// Blue
|
||||
[SerializeField] private Color enemyTeamColor = new Color(0.96f, 0.26f, 0.21f); // Red
|
||||
[SerializeField] private Color droppedPulseColor = new Color(1f, 0.84f, 0f); // Pulsing gold
|
||||
|
||||
[Header("Glow Settings")]
|
||||
[SerializeField] private float glowLightRange = 8f;
|
||||
[SerializeField] private float glowLightIntensity = 2f;
|
||||
[SerializeField] private Color glowLightColor = new Color(1f, 0.84f, 0f); // Gold
|
||||
[SerializeField] private float glowPulseSpeed = 2f;
|
||||
[SerializeField] private float glowPulseMin = 0.6f;
|
||||
[SerializeField] private float glowPulseMax = 1f;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("Sound effect played when this bag is picked up")]
|
||||
[SerializeField] private AudioClip pickupSound;
|
||||
[SerializeField] private float pickupSoundVolume = 3f;
|
||||
|
||||
// ─── Runtime State ───────────────────────────────────────
|
||||
private BagState _state = BagState.Available;
|
||||
private CashCarrier _carrier;
|
||||
private Vector3 _spawnPosition;
|
||||
private float _dropTime;
|
||||
private float _despawnTimer;
|
||||
private Collider _collider;
|
||||
private Light _glowLight;
|
||||
private float _enabledTime; // Time.time when this bag was last enabled
|
||||
|
||||
// Bob animation (randomized phase to desync multiple bags)
|
||||
private float _bobPhase;
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────
|
||||
public BagState State => _state;
|
||||
public float CashValue => cashValue;
|
||||
public float Value => cashValue; // Alias for AI compatibility
|
||||
public bool IsAvailable => _state == BagState.Available;
|
||||
public bool IsDropped => _state == BagState.Dropped;
|
||||
public bool IsCarried => _state == BagState.Carried;
|
||||
public bool IsHotDrop => _state == BagState.Dropped && (Time.time - _dropTime) < hotDropWindow;
|
||||
public float HotDropBonusPercent => hotDropBonusPercent;
|
||||
public CashCarrier CurrentCarrier => _carrier;
|
||||
public Vector3 SpawnPosition => _spawnPosition;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (meshRenderer == null) meshRenderer = GetComponent<MeshRenderer>();
|
||||
_collider = GetComponent<Collider>();
|
||||
|
||||
if (_collider != null) _collider.isTrigger = true;
|
||||
_bobPhase = Random.Range(0f, Mathf.PI * 2f); // Desync bobs between bags
|
||||
|
||||
// Create glow point light if none assigned
|
||||
CreateGlow();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EntityRegistry<CashBag>.Register(this);
|
||||
_spawnPosition = transform.position;
|
||||
_enabledTime = Time.time;
|
||||
SetAvailable();
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
// Ensure reasonable default scale when added in editor
|
||||
transform.localScale = targetScale;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EntityRegistry<CashBag>.Unregister(this);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
switch (_state)
|
||||
{
|
||||
case BagState.Available:
|
||||
AnimateIdle();
|
||||
break;
|
||||
|
||||
case BagState.Carried:
|
||||
// Position managed by CashCarrier
|
||||
break;
|
||||
|
||||
case BagState.Dropped:
|
||||
AnimateDropped();
|
||||
_despawnTimer += Time.deltaTime;
|
||||
if (_despawnTimer >= dropDespawnTime)
|
||||
Despawn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── State Transitions ───────────────────────────────────
|
||||
|
||||
/// <summary>Reset to neutral available state.</summary>
|
||||
public void SetAvailable()
|
||||
{
|
||||
_state = BagState.Available;
|
||||
_carrier = null;
|
||||
_despawnTimer = 0f;
|
||||
|
||||
if (_collider != null) _collider.enabled = true;
|
||||
if (meshRenderer != null) meshRenderer.enabled = true;
|
||||
gameObject.SetActive(true);
|
||||
|
||||
// Ensure consistent visual scale
|
||||
transform.localScale = targetScale;
|
||||
|
||||
SetColor(neutralColor);
|
||||
SetParticles(idleParticles, true);
|
||||
if (worldIndicator != null) worldIndicator.SetActive(false);
|
||||
|
||||
// Enable glow so players can see the bag from far away
|
||||
SetGlowActive(true);
|
||||
|
||||
// Show minimap marker when available
|
||||
var marker = GetComponent<MinimapObjectMarker>();
|
||||
if (marker != null) marker.SetVisible(true);
|
||||
}
|
||||
|
||||
/// <summary>Picked up by a carrier.</summary>
|
||||
public void PickUp(CashCarrier carrier)
|
||||
{
|
||||
if (_state == BagState.Carried || carrier == null) return;
|
||||
|
||||
_state = BagState.Carried;
|
||||
_carrier = carrier;
|
||||
|
||||
// Disable world presence
|
||||
if (_collider != null) _collider.enabled = false;
|
||||
if (meshRenderer != null) meshRenderer.enabled = false;
|
||||
if (worldIndicator != null) worldIndicator.SetActive(false);
|
||||
SetParticles(idleParticles, false);
|
||||
|
||||
// Burst pickup effect
|
||||
if (pickupBurstEffect != null)
|
||||
{
|
||||
pickupBurstEffect.transform.position = transform.position;
|
||||
pickupBurstEffect.Play();
|
||||
}
|
||||
|
||||
// Play pickup sound effect (play at camera position so volume is consistent)
|
||||
if (pickupSound != null)
|
||||
{
|
||||
Vector3 listenerPos = Camera.main != null ? Camera.main.transform.position : transform.position;
|
||||
AudioSource.PlayClipAtPoint(pickupSound, listenerPos, pickupSoundVolume);
|
||||
}
|
||||
|
||||
// Set team color
|
||||
Color teamColor = carrier.TeamTag == "Player" ? playerTeamColor : enemyTeamColor;
|
||||
SetColor(teamColor);
|
||||
|
||||
// Hide glow while carried
|
||||
SetGlowActive(false);
|
||||
|
||||
// Fire events (replaces FindObjectOfType calls)
|
||||
GameEvents.FireBagPickedUp(this, carrier);
|
||||
GameEvents.FireBagCollected(this);
|
||||
|
||||
// Hide minimap marker while carried
|
||||
var marker = GetComponent<MinimapObjectMarker>();
|
||||
if (marker != null) marker.SetVisible(false);
|
||||
}
|
||||
|
||||
/// <summary>Dropped at a world position (carrier died or lost it).</summary>
|
||||
public void Drop(Vector3 dropPosition)
|
||||
{
|
||||
_state = BagState.Dropped;
|
||||
_carrier = null;
|
||||
_dropTime = Time.time;
|
||||
_despawnTimer = 0f;
|
||||
|
||||
// Re-enable world presence
|
||||
transform.position = dropPosition + Vector3.up * 0.5f;
|
||||
if (_collider != null) _collider.enabled = true;
|
||||
if (meshRenderer != null) meshRenderer.enabled = true;
|
||||
|
||||
SetColor(droppedPulseColor);
|
||||
SetParticles(idleParticles, true);
|
||||
|
||||
// Re-enable glow when dropped
|
||||
SetGlowActive(true);
|
||||
|
||||
if (worldIndicator != null) worldIndicator.SetActive(true);
|
||||
|
||||
// Impact effect
|
||||
if (dropImpactEffect != null)
|
||||
{
|
||||
dropImpactEffect.transform.position = dropPosition;
|
||||
dropImpactEffect.Play();
|
||||
}
|
||||
|
||||
GameEvents.FireBagDropped(this, dropPosition);
|
||||
|
||||
// Show minimap marker when dropped in world
|
||||
var marker = GetComponent<MinimapObjectMarker>();
|
||||
if (marker != null) marker.SetVisible(true);
|
||||
}
|
||||
|
||||
/// <summary>Remove from play (despawn or deposited).</summary>
|
||||
public void Despawn()
|
||||
{
|
||||
_state = BagState.Available;
|
||||
_carrier = null;
|
||||
|
||||
SetParticles(idleParticles, false);
|
||||
SetGlowActive(false);
|
||||
// Hide from world
|
||||
var marker = GetComponent<MinimapObjectMarker>();
|
||||
if (marker != null) marker.SetVisible(false);
|
||||
gameObject.SetActive(false);
|
||||
GameEvents.FireBagDespawned(this);
|
||||
|
||||
// Return to pool if pooled, otherwise destroy
|
||||
var pooled = GetComponent<PooledObject>();
|
||||
if (pooled != null)
|
||||
pooled.ReturnToPool();
|
||||
else
|
||||
Destroy(gameObject, 0.1f);
|
||||
}
|
||||
|
||||
// ─── Trigger Detection ───────────────────────────────────
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (_state == BagState.Carried) return;
|
||||
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected) return;
|
||||
|
||||
// Grace period: prevent insta-pickup when bag spawns on top of player
|
||||
if (Time.time - _enabledTime < pickupGracePeriod) return;
|
||||
|
||||
// Use GetComponentInParent because collider may be on child (hand/foot)
|
||||
CashCarrier carrier = other.GetComponentInParent<CashCarrier>();
|
||||
if (carrier != null && carrier.CanPickUp())
|
||||
{
|
||||
PickUp(carrier);
|
||||
carrier.OnBagPickedUp(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Glow ─────────────────────────────────────────────────
|
||||
|
||||
private void CreateGlow()
|
||||
{
|
||||
// Use existing glowObject light if assigned in inspector
|
||||
if (glowObject != null)
|
||||
{
|
||||
_glowLight = glowObject.GetComponent<Light>();
|
||||
if (_glowLight != null) return;
|
||||
}
|
||||
|
||||
// Create a point light child for long-range visibility
|
||||
GameObject lightObj = new GameObject("BagGlow");
|
||||
lightObj.transform.SetParent(transform, false);
|
||||
lightObj.transform.localPosition = Vector3.up * 0.3f;
|
||||
_glowLight = lightObj.AddComponent<Light>();
|
||||
_glowLight.type = LightType.Point;
|
||||
_glowLight.color = glowLightColor;
|
||||
_glowLight.range = glowLightRange;
|
||||
_glowLight.intensity = glowLightIntensity;
|
||||
_glowLight.shadows = LightShadows.None;
|
||||
_glowLight.renderMode = LightRenderMode.Auto;
|
||||
|
||||
// Store as glowObject for other methods to reference
|
||||
if (glowObject == null)
|
||||
glowObject = lightObj;
|
||||
}
|
||||
|
||||
private void SetGlowActive(bool active)
|
||||
{
|
||||
if (_glowLight != null) _glowLight.enabled = active;
|
||||
if (glowObject != null) glowObject.SetActive(active);
|
||||
}
|
||||
|
||||
private void PulseGlow()
|
||||
{
|
||||
if (_glowLight == null) return;
|
||||
float pulse = Mathf.Lerp(glowPulseMin, glowPulseMax, (Mathf.Sin(Time.time * glowPulseSpeed + _bobPhase) + 1f) * 0.5f);
|
||||
_glowLight.intensity = glowLightIntensity * pulse;
|
||||
}
|
||||
|
||||
// ─── Animations ──────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
// Pulse glow
|
||||
PulseGlow();
|
||||
}
|
||||
|
||||
private void AnimateDropped()
|
||||
{
|
||||
// Faster pulse for hot drops
|
||||
float pulseSpeed = IsHotDrop ? bobSpeed * 2f : bobSpeed;
|
||||
float y = transform.position.y;
|
||||
float targetY = _spawnPosition.y + floatOffset + Mathf.Sin((Time.time + _bobPhase) * pulseSpeed) * bobHeight * 0.5f;
|
||||
|
||||
// Smooth lerp for drop→bob transition
|
||||
transform.position = new Vector3(transform.position.x, Mathf.Lerp(y, targetY, Time.deltaTime * 3f), transform.position.z);
|
||||
|
||||
// Pulse glow for hot drops
|
||||
if (IsHotDrop && glowObject != null)
|
||||
{
|
||||
float pulse = Mathf.PingPong(Time.time * 3f, 1f);
|
||||
glowObject.transform.localScale = Vector3.one * (1f + pulse * 0.3f);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Visual Helpers ──────────────────────────────────────
|
||||
|
||||
private void SetColor(Color color)
|
||||
{
|
||||
if (meshRenderer != null)
|
||||
{
|
||||
// MaterialPropertyBlock = zero allocation, no material instance created
|
||||
var mpb = new MaterialPropertyBlock();
|
||||
meshRenderer.GetPropertyBlock(mpb);
|
||||
mpb.SetColor("_BaseColor", color);
|
||||
mpb.SetColor("_EmissionColor", color * 1.2f); // Bright emission for visibility
|
||||
meshRenderer.SetPropertyBlock(mpb);
|
||||
|
||||
// Enable emission keyword if possible
|
||||
if (meshRenderer.sharedMaterial != null)
|
||||
meshRenderer.sharedMaterial.EnableKeyword("_EMISSION");
|
||||
}
|
||||
|
||||
if (glowObject != null)
|
||||
{
|
||||
Light light = glowObject.GetComponent<Light>();
|
||||
if (light != null) light.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetParticles(ParticleSystem ps, bool play)
|
||||
{
|
||||
if (ps == null) return;
|
||||
if (play && !ps.isPlaying) ps.Play();
|
||||
else if (!play && ps.isPlaying) ps.Stop(true, ParticleSystemStopBehavior.StopEmitting);
|
||||
}
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Set the cash value (used by spawner).</summary>
|
||||
public void SetCashValue(float value) => cashValue = value;
|
||||
|
||||
/// <summary>Get effective value including hot drop bonus.</summary>
|
||||
public float GetEffectiveValue()
|
||||
{
|
||||
if (IsHotDrop)
|
||||
return cashValue * (1f + hotDropBonusPercent / 100f);
|
||||
return cashValue;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawWireSphere(transform.position, 1.5f);
|
||||
UnityEditor.Handles.Label(transform.position + Vector3.up * 1.5f,
|
||||
$"CashBag ${cashValue}\n{_state}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/CashBag.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashBag.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ba5b44417d2bdcb898326c6c046307c
|
||||
685
Assets/Scripts/CashSystem/CashBagSpawner.cs
Normal file
685
Assets/Scripts/CashSystem/CashBagSpawner.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Spawn zone classification for cash bags.
|
||||
/// Contested zones are between team vaults (center of map) — forces team encounters.
|
||||
/// </summary>
|
||||
public enum SpawnZone
|
||||
{
|
||||
Safe, // Near a vault — low risk, low reward
|
||||
Neutral, // Between center and vaults — medium risk
|
||||
Contested // Center of map between vaults — high risk, high reward
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CashBagSpawner — Wave-based spawning of cash bags with object pooling.
|
||||
/// Uses zone-weighted spawning to force team encounters in contested areas.
|
||||
/// </summary>
|
||||
public class CashBagSpawner : MonoBehaviour
|
||||
{
|
||||
[Header("Cash Bag Settings")]
|
||||
[SerializeField] private GameObject cashBagPrefab;
|
||||
[SerializeField] private float cashPerBag = 100f;
|
||||
|
||||
[Header("Spawn Limits")]
|
||||
[SerializeField] private int maxBagCount = 15;
|
||||
[SerializeField] private float initialSpawnPercent = 0.60f; // 60% on start
|
||||
|
||||
[Header("Wave Settings")]
|
||||
[SerializeField] private float waveInterval = 30f;
|
||||
[SerializeField] private int bagsPerWave = 3;
|
||||
|
||||
[Header("Spawn Area")]
|
||||
[SerializeField] private Transform[] spawnPoints;
|
||||
[SerializeField] private bool useRandomSpawning = false;
|
||||
[SerializeField] private Vector3 spawnAreaCenter = Vector3.zero;
|
||||
[SerializeField] private Vector3 spawnAreaSize = new Vector3(20f, 0f, 20f);
|
||||
[SerializeField] private float minSpawnDistance = 5f; // Min distance between bags
|
||||
[SerializeField] private float playerAvoidDistance = 5f; // Distance from players
|
||||
[SerializeField] private float vaultAvoidDistance = 6f; // Distance from vaults
|
||||
|
||||
[Header("Zone-Weighted Spawning")]
|
||||
[Tooltip("Chance to spawn in contested center zone (forces team encounters)")]
|
||||
[SerializeField, Range(0f, 1f)] private float contestedZoneWeight = 0.60f;
|
||||
[Tooltip("Chance to spawn in neutral middle zone")]
|
||||
[SerializeField, Range(0f, 1f)] private float neutralZoneWeight = 0.30f;
|
||||
[Tooltip("Chance to spawn in safe zone near vaults")]
|
||||
[SerializeField, Range(0f, 1f)] private float safeZoneWeight = 0.10f;
|
||||
[Tooltip("Value multiplier for contested zone bags")]
|
||||
[SerializeField] private float contestedValueMultiplier = 2.0f;
|
||||
[Tooltip("Value multiplier for neutral zone bags")]
|
||||
[SerializeField] private float neutralValueMultiplier = 1.0f;
|
||||
[Tooltip("Value multiplier for safe zone bags")]
|
||||
[SerializeField] private float safeValueMultiplier = 0.5f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool showSpawnGizmos = true;
|
||||
|
||||
// ─── Runtime ─────────────────────────────────────────────
|
||||
private ObjectPool _pool;
|
||||
private readonly List<CashBag> _activeBags = new List<CashBag>();
|
||||
private readonly HashSet<int> _occupiedSpawnPoints = new HashSet<int>(); // tracks which spawn points have an active bag
|
||||
private readonly Dictionary<int, float> _spawnPointCooldowns = new Dictionary<int, float>(); // prevents same-point reuse
|
||||
private const float SPAWN_POINT_COOLDOWN = 8f; // seconds before a freed point can be reused
|
||||
private int _totalSpawned;
|
||||
private bool _isSpawning;
|
||||
private Coroutine _waveCoroutine;
|
||||
|
||||
// ─── Zone Classification ─────────────────────────────────
|
||||
private readonly List<int> _contestedPoints = new List<int>();
|
||||
private readonly List<int> _neutralPoints = new List<int>();
|
||||
private readonly List<int> _safePoints = new List<int>();
|
||||
private bool _zonesClassified;
|
||||
private Vector3 _mapCenter; // midpoint between vaults — the contested zone center
|
||||
|
||||
public int ActiveBagCount => _activeBags.Count;
|
||||
public int TotalSpawned => _totalSpawned;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find spawn points by tag if none assigned
|
||||
if ((spawnPoints == null || spawnPoints.Length == 0) && !useRandomSpawning)
|
||||
{
|
||||
GameObject[] spawnPointObjects = GameObject.FindGameObjectsWithTag("CashBagSpawn");
|
||||
if (spawnPointObjects.Length > 0)
|
||||
{
|
||||
spawnPoints = new Transform[spawnPointObjects.Length];
|
||||
for (int i = 0; i < spawnPointObjects.Length; i++)
|
||||
spawnPoints[i] = spawnPointObjects[i].transform;
|
||||
}
|
||||
else
|
||||
{
|
||||
useRandomSpawning = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize object pool (pre-warm up to maxBagCount and parent under this transform)
|
||||
if (cashBagPrefab != null)
|
||||
{
|
||||
_pool = new ObjectPool(cashBagPrefab, initialSize: maxBagCount, maxSize: maxBagCount, parent: transform);
|
||||
}
|
||||
|
||||
// Classify spawn points into zones for weighted spawning
|
||||
ClassifySpawnPoints();
|
||||
|
||||
StartSpawning();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnBagDespawned += OnBagDespawned;
|
||||
// NOTE: We intentionally do NOT subscribe to OnBagCollected here.
|
||||
// A bag being picked up is NOT the same as a bag leaving the field —
|
||||
// the carrier is still running around with it. We only remove from
|
||||
// _activeBags when the bag is truly gone (despawned/deposited).
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnBagDespawned -= OnBagDespawned;
|
||||
}
|
||||
|
||||
// ─── Spawning ────────────────────────────────────────────
|
||||
|
||||
public void StartSpawning()
|
||||
{
|
||||
if (_isSpawning) return;
|
||||
_isSpawning = true;
|
||||
|
||||
int initialCount = Mathf.RoundToInt(maxBagCount * initialSpawnPercent);
|
||||
|
||||
for (int i = 0; i < initialCount; i++)
|
||||
SpawnBag();
|
||||
|
||||
GameEvents.FireActiveBagCountChanged(_activeBags.Count);
|
||||
GameEvents.FirePopupMessage("CASH DROPPED!", 1.5f);
|
||||
|
||||
// Start wave timer
|
||||
_waveCoroutine = StartCoroutine(WaveSpawnRoutine());
|
||||
}
|
||||
|
||||
private IEnumerator WaveSpawnRoutine()
|
||||
{
|
||||
while (_isSpawning)
|
||||
{
|
||||
yield return new WaitForSeconds(waveInterval);
|
||||
if (!_isSpawning) break;
|
||||
|
||||
// Clean up null references
|
||||
_activeBags.RemoveAll(b => b == null || !b.gameObject.activeInHierarchy);
|
||||
|
||||
int canSpawn = maxBagCount - _activeBags.Count;
|
||||
int toSpawn = Mathf.Min(bagsPerWave, canSpawn);
|
||||
|
||||
if (toSpawn <= 0) continue;
|
||||
|
||||
for (int i = 0; i < toSpawn; i++)
|
||||
{
|
||||
SpawnBag();
|
||||
yield return new WaitForSeconds(0.15f); // Stagger
|
||||
}
|
||||
|
||||
GameEvents.FireActiveBagCountChanged(_activeBags.Count);
|
||||
GameEvents.FirePopupMessage($"+{toSpawn} CASH!", 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnBag()
|
||||
{
|
||||
if (cashBagPrefab == null) return;
|
||||
|
||||
// Determine spawn zone for value multiplier
|
||||
SpawnZone zone = _zonesClassified ? PickWeightedZone() : SpawnZone.Neutral;
|
||||
|
||||
Vector3 spawnPos = _zonesClassified ? GetZoneSpawnPosition(zone) : GetValidSpawnPosition();
|
||||
if (spawnPos == Vector3.zero && !useRandomSpawning) return;
|
||||
|
||||
// Snap to NavMesh
|
||||
if (NavMesh.SamplePosition(spawnPos, out NavMeshHit navHit, 5f, NavMesh.AllAreas))
|
||||
spawnPos = navHit.position + Vector3.up * 0.7f;
|
||||
|
||||
// Use pool if available, fallback to Instantiate
|
||||
GameObject bagObj;
|
||||
if (_pool != null)
|
||||
{
|
||||
bagObj = _pool.Get(spawnPos, Quaternion.identity);
|
||||
if (bagObj == null)
|
||||
{
|
||||
bagObj = Instantiate(cashBagPrefab, spawnPos, Quaternion.identity);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bagObj = Instantiate(cashBagPrefab, spawnPos, Quaternion.identity);
|
||||
}
|
||||
|
||||
CashBag bag = bagObj.GetComponent<CashBag>();
|
||||
if (bag == null) bag = bagObj.AddComponent<CashBag>();
|
||||
|
||||
// Apply zone-based value multiplier — contested bags are worth MORE
|
||||
float zoneMultiplier = GetZoneValueMultiplier(zone);
|
||||
bag.SetCashValue(cashPerBag * zoneMultiplier);
|
||||
_activeBags.Add(bag);
|
||||
_totalSpawned++;
|
||||
|
||||
// Ensure the bag gets a minimap marker
|
||||
if (bag.GetComponent<MinimapObjectMarker>() == null)
|
||||
bag.gameObject.AddComponent<MinimapObjectMarker>();
|
||||
|
||||
GameEvents.FireBagSpawned(bag);
|
||||
}
|
||||
|
||||
// ─── Zone Classification ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Classifies each spawn point as Contested, Neutral, or Safe based on
|
||||
/// its distance from the map center (midpoint between team vaults).
|
||||
/// This ensures bags spawn more often in areas where teams will clash.
|
||||
/// </summary>
|
||||
private void ClassifySpawnPoints()
|
||||
{
|
||||
if (spawnPoints == null || spawnPoints.Length == 0 || useRandomSpawning)
|
||||
{
|
||||
_zonesClassified = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_contestedPoints.Clear();
|
||||
_neutralPoints.Clear();
|
||||
_safePoints.Clear();
|
||||
|
||||
// Find vault positions to determine map layout
|
||||
var vaults = EntityRegistry<TeamVault>.GetAll();
|
||||
if (vaults == null || vaults.Count < 2)
|
||||
{
|
||||
// Can't classify without vaults — treat all as neutral
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
if (spawnPoints[i] != null)
|
||||
_neutralPoints.Add(i);
|
||||
_zonesClassified = true;
|
||||
_mapCenter = spawnAreaCenter;
|
||||
DevLog.Log("[CashBagSpawner] <2 vaults found, all spawn points classified as Neutral.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate map center (midpoint between vaults — XZ plane)
|
||||
Vector3 v0 = vaults[0].transform.position;
|
||||
Vector3 v1 = vaults[1].transform.position;
|
||||
_mapCenter = (v0 + v1) * 0.5f;
|
||||
_mapCenter.y = 0f; // Normalize to ground
|
||||
|
||||
// Find the maximum distance from center to any spawn point (for normalization)
|
||||
float maxDist = 0f;
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
{
|
||||
if (spawnPoints[i] == null) continue;
|
||||
Vector3 flatPos = spawnPoints[i].position;
|
||||
flatPos.y = 0f;
|
||||
float d = Vector3.Distance(flatPos, _mapCenter);
|
||||
if (d > maxDist) maxDist = d;
|
||||
}
|
||||
|
||||
if (maxDist < 1f) maxDist = 1f; // Prevent division by zero
|
||||
|
||||
// Classify: inner 40% = contested, 40-70% = neutral, outer 70%+ = safe
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
{
|
||||
if (spawnPoints[i] == null) continue;
|
||||
|
||||
Vector3 flatPos = spawnPoints[i].position;
|
||||
flatPos.y = 0f;
|
||||
float normalizedDist = Vector3.Distance(flatPos, _mapCenter) / maxDist;
|
||||
|
||||
if (normalizedDist < 0.40f)
|
||||
_contestedPoints.Add(i);
|
||||
else if (normalizedDist < 0.70f)
|
||||
_neutralPoints.Add(i);
|
||||
else
|
||||
_safePoints.Add(i);
|
||||
}
|
||||
|
||||
_zonesClassified = true;
|
||||
DevLog.Log($"[CashBagSpawner] Zone classification: " +
|
||||
$"Contested={_contestedPoints.Count}, " +
|
||||
$"Neutral={_neutralPoints.Count}, " +
|
||||
$"Safe={_safePoints.Count}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a spawn zone based on weighted random distribution.
|
||||
/// 60% contested, 30% neutral, 10% safe by default.
|
||||
/// Falls back to any available zone if the chosen zone is empty.
|
||||
/// </summary>
|
||||
private SpawnZone PickWeightedZone()
|
||||
{
|
||||
float r = Random.value;
|
||||
float cumulative = 0f;
|
||||
|
||||
cumulative += contestedZoneWeight;
|
||||
if (r < cumulative && _contestedPoints.Count > 0)
|
||||
return SpawnZone.Contested;
|
||||
|
||||
cumulative += neutralZoneWeight;
|
||||
if (r < cumulative && _neutralPoints.Count > 0)
|
||||
return SpawnZone.Neutral;
|
||||
|
||||
if (_safePoints.Count > 0)
|
||||
return SpawnZone.Safe;
|
||||
|
||||
// Fallback: pick whichever zone has points
|
||||
if (_contestedPoints.Count > 0) return SpawnZone.Contested;
|
||||
if (_neutralPoints.Count > 0) return SpawnZone.Neutral;
|
||||
if (_safePoints.Count > 0) return SpawnZone.Safe;
|
||||
|
||||
return SpawnZone.Neutral; // absolute fallback
|
||||
}
|
||||
|
||||
/// <summary>Returns the value multiplier for a given spawn zone.</summary>
|
||||
private float GetZoneValueMultiplier(SpawnZone zone)
|
||||
{
|
||||
switch (zone)
|
||||
{
|
||||
case SpawnZone.Contested: return contestedValueMultiplier;
|
||||
case SpawnZone.Neutral: return neutralValueMultiplier;
|
||||
case SpawnZone.Safe: return safeValueMultiplier;
|
||||
default: return 1f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the spawn point list for a given zone.</summary>
|
||||
private List<int> GetZonePoints(SpawnZone zone)
|
||||
{
|
||||
switch (zone)
|
||||
{
|
||||
case SpawnZone.Contested: return _contestedPoints;
|
||||
case SpawnZone.Neutral: return _neutralPoints;
|
||||
case SpawnZone.Safe: return _safePoints;
|
||||
default: return _neutralPoints;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Position Validation ─────────────────────────────────
|
||||
|
||||
private Vector3 GetValidSpawnPosition()
|
||||
{
|
||||
return useRandomSpawning ? GetRandomSpawnPosition() : GetSpawnPointPosition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zone-aware spawn position selection. Picks from the specified zone's spawn points,
|
||||
/// falling back to other zones if all points in the chosen zone are occupied.
|
||||
/// </summary>
|
||||
private Vector3 GetZoneSpawnPosition(SpawnZone preferredZone)
|
||||
{
|
||||
if (!_zonesClassified || useRandomSpawning)
|
||||
return GetValidSpawnPosition();
|
||||
|
||||
// Try preferred zone first
|
||||
Vector3 pos = GetSpawnPointFromZone(GetZonePoints(preferredZone));
|
||||
if (pos != Vector3.zero) return pos;
|
||||
|
||||
// Fallback: try other zones (contested > neutral > safe priority)
|
||||
if (preferredZone != SpawnZone.Contested)
|
||||
{
|
||||
pos = GetSpawnPointFromZone(_contestedPoints);
|
||||
if (pos != Vector3.zero) return pos;
|
||||
}
|
||||
if (preferredZone != SpawnZone.Neutral)
|
||||
{
|
||||
pos = GetSpawnPointFromZone(_neutralPoints);
|
||||
if (pos != Vector3.zero) return pos;
|
||||
}
|
||||
if (preferredZone != SpawnZone.Safe)
|
||||
{
|
||||
pos = GetSpawnPointFromZone(_safePoints);
|
||||
if (pos != Vector3.zero) return pos;
|
||||
}
|
||||
|
||||
// Absolute fallback: original unfiltered method
|
||||
return GetSpawnPointPosition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks a valid spawn position from a specific set of spawn point indices.
|
||||
/// </summary>
|
||||
private Vector3 GetSpawnPointFromZone(List<int> zoneIndices)
|
||||
{
|
||||
if (zoneIndices == null || zoneIndices.Count == 0)
|
||||
return Vector3.zero;
|
||||
|
||||
// Clean up expired cooldowns
|
||||
List<int> expired = null;
|
||||
foreach (var kvp in _spawnPointCooldowns)
|
||||
{
|
||||
if (Time.time >= kvp.Value)
|
||||
{
|
||||
if (expired == null) expired = new List<int>(4);
|
||||
expired.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
if (expired != null)
|
||||
foreach (int idx in expired)
|
||||
_spawnPointCooldowns.Remove(idx);
|
||||
|
||||
// Shuffle to randomize within zone
|
||||
List<int> shuffled = new List<int>(zoneIndices);
|
||||
for (int i = shuffled.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Range(0, i + 1);
|
||||
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
|
||||
}
|
||||
|
||||
// Pick first unoccupied, valid, non-cooldown point
|
||||
foreach (int idx in shuffled)
|
||||
{
|
||||
if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue;
|
||||
if (_occupiedSpawnPoints.Contains(idx)) continue;
|
||||
if (_spawnPointCooldowns.ContainsKey(idx)) continue;
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_occupiedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: allow cooldown points in this zone
|
||||
foreach (int idx in shuffled)
|
||||
{
|
||||
if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue;
|
||||
if (_occupiedSpawnPoints.Contains(idx)) continue;
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_occupiedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
private Vector3 GetSpawnPointPosition()
|
||||
{
|
||||
if (spawnPoints == null || spawnPoints.Length == 0)
|
||||
return Vector3.zero;
|
||||
|
||||
// Clean up expired cooldowns
|
||||
List<int> expired = null;
|
||||
foreach (var kvp in _spawnPointCooldowns)
|
||||
{
|
||||
if (Time.time >= kvp.Value)
|
||||
{
|
||||
if (expired == null) expired = new List<int>(4);
|
||||
expired.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
if (expired != null)
|
||||
foreach (int idx in expired)
|
||||
_spawnPointCooldowns.Remove(idx);
|
||||
|
||||
// Build a list of indices, then shuffle to randomize
|
||||
List<int> indices = new List<int>(spawnPoints.Length);
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
indices.Add(i);
|
||||
|
||||
for (int i = indices.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Range(0, i + 1);
|
||||
(indices[i], indices[j]) = (indices[j], indices[i]);
|
||||
}
|
||||
|
||||
// Pick the first unoccupied, valid, non-cooldown spawn point
|
||||
foreach (int idx in indices)
|
||||
{
|
||||
if (spawnPoints[idx] == null) continue;
|
||||
if (_occupiedSpawnPoints.Contains(idx)) continue; // skip occupied
|
||||
if (_spawnPointCooldowns.ContainsKey(idx)) continue; // skip recently used
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_occupiedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: allow cooldown points if nothing else available
|
||||
foreach (int idx in indices)
|
||||
{
|
||||
if (spawnPoints[idx] == null) continue;
|
||||
if (_occupiedSpawnPoints.Contains(idx)) continue;
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_occupiedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
private Vector3 GetRandomSpawnPosition()
|
||||
{
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
Vector3 randomPos = spawnAreaCenter + new Vector3(
|
||||
Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2),
|
||||
0.5f,
|
||||
Random.Range(-spawnAreaSize.z / 2, spawnAreaSize.z / 2)
|
||||
);
|
||||
|
||||
if (IsPositionValid(randomPos))
|
||||
return randomPos;
|
||||
}
|
||||
|
||||
return spawnAreaCenter + new Vector3(Random.Range(-5f, 5f), 0.5f, Random.Range(-5f, 5f));
|
||||
}
|
||||
|
||||
private bool IsPositionValid(Vector3 pos)
|
||||
{
|
||||
// Check distance from active bags (using EntityRegistry — zero alloc)
|
||||
var bags = EntityRegistry<CashBag>.GetAll();
|
||||
for (int i = 0; i < bags.Count; i++)
|
||||
{
|
||||
if (bags[i] != null && Vector3.Distance(pos, bags[i].transform.position) < minSpawnDistance)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance from all characters (using TeamMember static registry)
|
||||
foreach (TeamMember member in TeamMember.AllMembers)
|
||||
{
|
||||
if (member != null && Vector3.Distance(pos, member.transform.position) < playerAvoidDistance)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance from vaults
|
||||
var vaults = EntityRegistry<TeamVault>.GetAll();
|
||||
for (int i = 0; i < vaults.Count; i++)
|
||||
{
|
||||
if (vaults[i] != null && Vector3.Distance(pos, vaults[i].transform.position) < vaultAvoidDistance)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Event Handlers ──────────────────────────────────────
|
||||
|
||||
private void OnBagDespawned(CashBag bag)
|
||||
{
|
||||
_activeBags.Remove(bag);
|
||||
ReleaseSpawnPoint(bag);
|
||||
GameEvents.FireActiveBagCountChanged(_activeBags.Count);
|
||||
|
||||
// Immediately spawn a replacement at a DIFFERENT position
|
||||
// so the field always has bags and they rotate across spawn points
|
||||
if (_isSpawning && _activeBags.Count < maxBagCount)
|
||||
{
|
||||
StartCoroutine(DelayedRespawn(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Respawn a bag after a short delay at a different position.</summary>
|
||||
private IEnumerator DelayedRespawn(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
if (!_isSpawning) yield break;
|
||||
|
||||
// Clean up null references
|
||||
_activeBags.RemoveAll(b => b == null || !b.gameObject.activeInHierarchy);
|
||||
|
||||
if (_activeBags.Count < maxBagCount)
|
||||
{
|
||||
SpawnBag();
|
||||
GameEvents.FireActiveBagCountChanged(_activeBags.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called when a bag is collected (legacy API for compatibility).</summary>
|
||||
public void OnBagCollected(CashBag bag)
|
||||
{
|
||||
// Legacy API: only remove if bag is truly gone (inactive).
|
||||
// Don't remove carried bags — they're still in play.
|
||||
if (bag != null && !bag.gameObject.activeInHierarchy)
|
||||
{
|
||||
if (_activeBags.Contains(bag))
|
||||
_activeBags.Remove(bag);
|
||||
ReleaseSpawnPoint(bag);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free the spawn point closest to a removed bag so it can be reused.
|
||||
/// Applies a cooldown so the same point isn't immediately reused.
|
||||
/// </summary>
|
||||
private void ReleaseSpawnPoint(CashBag bag)
|
||||
{
|
||||
if (bag == null || spawnPoints == null) return;
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
int bestIdx = -1;
|
||||
|
||||
foreach (int idx in _occupiedSpawnPoints)
|
||||
{
|
||||
if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue;
|
||||
float dist = Vector3.Distance(bag.SpawnPosition, spawnPoints[idx].position);
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestIdx = idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Only release if the bag was reasonably close to the spawn point
|
||||
if (bestIdx >= 0 && bestDist < minSpawnDistance * 2f)
|
||||
{
|
||||
_occupiedSpawnPoints.Remove(bestIdx);
|
||||
// Add cooldown so next spawn goes to a DIFFERENT position
|
||||
_spawnPointCooldowns[bestIdx] = Time.time + SPAWN_POINT_COOLDOWN;
|
||||
}
|
||||
}
|
||||
|
||||
public void StopSpawning()
|
||||
{
|
||||
_isSpawning = false;
|
||||
if (_waveCoroutine != null)
|
||||
StopCoroutine(_waveCoroutine);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
StopSpawning();
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!showSpawnGizmos) return;
|
||||
|
||||
Gizmos.color = new Color(0, 1, 0, 0.3f);
|
||||
Gizmos.DrawCube(spawnAreaCenter, spawnAreaSize);
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawWireCube(spawnAreaCenter, spawnAreaSize);
|
||||
|
||||
if (spawnPoints != null)
|
||||
{
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
{
|
||||
if (spawnPoints[i] == null) continue;
|
||||
|
||||
// Color by zone classification
|
||||
if (_zonesClassified)
|
||||
{
|
||||
if (_contestedPoints.Contains(i))
|
||||
Gizmos.color = Color.red; // Contested = RED (danger zone)
|
||||
else if (_neutralPoints.Contains(i))
|
||||
Gizmos.color = Color.yellow; // Neutral = YELLOW
|
||||
else if (_safePoints.Contains(i))
|
||||
Gizmos.color = Color.green; // Safe = GREEN
|
||||
else
|
||||
Gizmos.color = Color.white;
|
||||
}
|
||||
else
|
||||
{
|
||||
Gizmos.color = Color.yellow;
|
||||
}
|
||||
|
||||
Gizmos.DrawSphere(spawnPoints[i].position, 0.5f);
|
||||
}
|
||||
|
||||
// Draw map center (contested zone center)
|
||||
if (_zonesClassified)
|
||||
{
|
||||
Gizmos.color = new Color(1f, 0f, 0f, 0.5f);
|
||||
Gizmos.DrawWireSphere(_mapCenter, 3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/CashBagSpawner.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashBagSpawner.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a1fa35c76b8d04cfb343b59d172b2ab
|
||||
679
Assets/Scripts/CashSystem/CashBoxImportController.cs
Normal file
679
Assets/Scripts/CashSystem/CashBoxImportController.cs
Normal file
@@ -0,0 +1,679 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// CashBoxImportController — Manages cash import to a TeamVault's CashBox.
|
||||
/// Features:
|
||||
/// - Spawns CashBox prefab at vault position with correct transform
|
||||
/// - Countdown window based on import amount
|
||||
/// - Reverse-plays "close" animation to open, then plays forward to close
|
||||
/// - Camera focuses on player and cashbox during import
|
||||
/// - Freezes player movement during import
|
||||
/// - Sound effects for open/close/counting
|
||||
///
|
||||
/// Attach to the TeamVault or a manager object.
|
||||
/// </summary>
|
||||
public class CashBoxImportController : MonoBehaviour
|
||||
{
|
||||
[Header("CashBox Prefab")]
|
||||
[Tooltip("CashBox prefab to spawn at the vault")]
|
||||
[SerializeField] private GameObject cashBoxPrefab;
|
||||
|
||||
[Header("Spawn Transform (from prefab inspector)")]
|
||||
[SerializeField] private Vector3 spawnOffset = Vector3.zero;
|
||||
[SerializeField] private Vector3 spawnRotation = new Vector3(-90f, 0f, 0f);
|
||||
[SerializeField] private Vector3 spawnScale = new Vector3(60f, 60f, 60f);
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Name of the close animation state")]
|
||||
[SerializeField] private string closeAnimationName = "close";
|
||||
[SerializeField] private float openAnimationDuration = 1f;
|
||||
[SerializeField] private float closeAnimationDuration = 1f;
|
||||
|
||||
[Header("Import Settings")]
|
||||
[Tooltip("Seconds per $100 imported")]
|
||||
[SerializeField] private float countdownPerHundred = 0.5f;
|
||||
[SerializeField] private float minimumCountdown = 1f;
|
||||
[SerializeField] private float maximumCountdown = 10f;
|
||||
|
||||
[Header("Camera Settings")]
|
||||
[SerializeField] private float cameraFocusDistance = 5f;
|
||||
[SerializeField] private float cameraFocusHeight = 2f;
|
||||
[SerializeField] private float cameraSmoothSpeed = 5f;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("Sound when CashBox opens")]
|
||||
[SerializeField] private AudioClip openSound;
|
||||
[Tooltip("Sound when CashBox closes")]
|
||||
[SerializeField] private AudioClip closeSound;
|
||||
[Tooltip("Looping sound during cash counting")]
|
||||
[SerializeField] private AudioClip countingSound;
|
||||
[Tooltip("Sound when import completes")]
|
||||
[SerializeField] private AudioClip completeSound;
|
||||
[SerializeField] private float soundVolume = 1f;
|
||||
|
||||
[Header("UI References")]
|
||||
[SerializeField] private GameObject importUIPanel;
|
||||
[SerializeField] private TMP_Text countdownText;
|
||||
[Tooltip("Separate TMP for status label (e.g. 'IMPORTING'). Assign a TextMeshPro in your scene.")]
|
||||
[SerializeField] private TMP_Text statusText;
|
||||
[SerializeField] private TMP_Text amountText;
|
||||
[SerializeField] private Image progressDial;
|
||||
[Tooltip("Optional sprite for the circular dial (e.g. Thick_Dial)")]
|
||||
[SerializeField] private Sprite dialSprite;
|
||||
[Header("Scene References")]
|
||||
[Tooltip("Drag the TimeDial GameObject from the scene here (will use its Image component)")]
|
||||
[SerializeField] private GameObject progressDialObject;
|
||||
|
||||
// Runtime
|
||||
private GameObject _spawnedCashBox;
|
||||
private Animator _cashBoxAnimator;
|
||||
private AudioSource _audioSource;
|
||||
private TeamVault _vault;
|
||||
private Camera _mainCamera;
|
||||
private Vector3 _originalCameraPosition;
|
||||
private Quaternion _originalCameraRotation;
|
||||
private Transform _originalCameraParent;
|
||||
private bool _isImporting;
|
||||
private CashCarrier _currentCarrier;
|
||||
|
||||
public bool IsImporting => _isImporting;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_vault = GetComponent<TeamVault>();
|
||||
_audioSource = GetComponent<AudioSource>();
|
||||
if (_audioSource == null)
|
||||
_audioSource = gameObject.AddComponent<AudioSource>();
|
||||
|
||||
_audioSource.playOnAwake = false;
|
||||
_audioSource.spatialBlend = 0f; // 2D sound for UI feedback
|
||||
|
||||
_mainCamera = Camera.main;
|
||||
|
||||
// If a GameObject was provided (e.g. your TimeDial), try to grab its Image component
|
||||
if (progressDial == null && progressDialObject != null)
|
||||
{
|
||||
var img = progressDialObject.GetComponent<Image>();
|
||||
if (img == null) img = progressDialObject.GetComponentInChildren<Image>();
|
||||
if (img != null)
|
||||
{
|
||||
progressDial = img;
|
||||
Debug.Log("[CashBoxImportController] Assigned progressDial from progressDialObject");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[CashBoxImportController] progressDialObject does not contain an Image component");
|
||||
}
|
||||
}
|
||||
|
||||
// If a dial sprite was provided and progressDial exists, apply it
|
||||
if (progressDial != null && dialSprite != null)
|
||||
{
|
||||
progressDial.sprite = dialSprite;
|
||||
progressDial.type = Image.Type.Filled;
|
||||
progressDial.fillMethod = Image.FillMethod.Radial360;
|
||||
progressDial.fillOrigin = (int)Image.Origin360.Top;
|
||||
progressDial.fillClockwise = true;
|
||||
progressDial.fillAmount = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Auto-setup UI if not assigned
|
||||
if (importUIPanel == null)
|
||||
CreateImportUI();
|
||||
else
|
||||
importUIPanel.SetActive(false);
|
||||
|
||||
// Auto-spawn CashBox on start if prefab is assigned
|
||||
if (cashBoxPrefab != null && _spawnedCashBox == null)
|
||||
SpawnCashBox();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure the controller from TeamVault at runtime (when added dynamically).
|
||||
/// </summary>
|
||||
public void Configure(GameObject prefab, Vector3 offset, Vector3 rotation, Vector3 scale)
|
||||
{
|
||||
cashBoxPrefab = prefab;
|
||||
spawnOffset = offset;
|
||||
spawnRotation = rotation;
|
||||
spawnScale = scale;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn the CashBox at vault position. Call this on game start or when needed.
|
||||
/// </summary>
|
||||
public void SpawnCashBox()
|
||||
{
|
||||
if (cashBoxPrefab == null)
|
||||
{
|
||||
Debug.LogWarning("[CashBoxImportController] No CashBox prefab assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_spawnedCashBox != null)
|
||||
{
|
||||
Debug.Log("[CashBoxImportController] CashBox already spawned");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the vault's own visual mesh/renderers so only the CashBox is visible
|
||||
HideVaultVisuals();
|
||||
|
||||
// Spawn at vault position with specified transform
|
||||
Vector3 spawnPos = transform.position + spawnOffset;
|
||||
Quaternion spawnRot = Quaternion.Euler(spawnRotation);
|
||||
|
||||
_spawnedCashBox = Instantiate(cashBoxPrefab, spawnPos, spawnRot, transform);
|
||||
_spawnedCashBox.transform.localScale = spawnScale;
|
||||
_spawnedCashBox.name = "CashBox_" + (_vault != null ? _vault.TeamTag : "Vault");
|
||||
|
||||
_cashBoxAnimator = _spawnedCashBox.GetComponent<Animator>();
|
||||
if (_cashBoxAnimator == null)
|
||||
_cashBoxAnimator = _spawnedCashBox.GetComponentInChildren<Animator>();
|
||||
|
||||
Debug.Log($"[CashBoxImportController] Spawned CashBox at {spawnPos}, rotation {spawnRotation}, scale {spawnScale}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable the vault's own mesh renderers so only the CashBox prefab shows.
|
||||
/// Skips any renderers on the spawned CashBox itself.
|
||||
/// </summary>
|
||||
private void HideVaultVisuals()
|
||||
{
|
||||
// Disable all renderers on the vault GameObject (not children that are the CashBox)
|
||||
foreach (var renderer in GetComponents<Renderer>())
|
||||
{
|
||||
renderer.enabled = false;
|
||||
}
|
||||
|
||||
// Also disable renderers on existing vault children (the old "vault box")
|
||||
// but skip any previously spawned CashBox
|
||||
for (int i = 0; i < transform.childCount; i++)
|
||||
{
|
||||
Transform child = transform.GetChild(i);
|
||||
if (_spawnedCashBox != null && child.gameObject == _spawnedCashBox) continue;
|
||||
if (child.name.StartsWith("CashBox_")) continue; // Skip our CashBox
|
||||
|
||||
foreach (var renderer in child.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
renderer.enabled = false;
|
||||
Debug.Log($"[CashBoxImportController] Disabled vault visual: {renderer.gameObject.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start the import process for a carrier depositing cash.
|
||||
/// </summary>
|
||||
public void StartImport(CashCarrier carrier)
|
||||
{
|
||||
if (_isImporting)
|
||||
{
|
||||
Debug.LogWarning("[CashBoxImportController] Import already in progress!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (carrier == null || carrier.CarriedCash <= 0)
|
||||
{
|
||||
Debug.LogWarning("[CashBoxImportController] No cash to import!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn CashBox if not already
|
||||
if (_spawnedCashBox == null)
|
||||
SpawnCashBox();
|
||||
|
||||
if (_spawnedCashBox == null)
|
||||
{
|
||||
Debug.LogError("[CashBoxImportController] Failed to spawn CashBox!");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentCarrier = carrier;
|
||||
StartCoroutine(ImportRoutine(carrier));
|
||||
}
|
||||
|
||||
private IEnumerator ImportRoutine(CashCarrier carrier)
|
||||
{
|
||||
_isImporting = true;
|
||||
float cashAmount = carrier.CarriedCash;
|
||||
|
||||
// Calculate countdown based on amount
|
||||
float countdownDuration = Mathf.Clamp(
|
||||
(cashAmount / 100f) * countdownPerHundred,
|
||||
minimumCountdown,
|
||||
maximumCountdown
|
||||
);
|
||||
|
||||
Debug.Log($"[CashBoxImportController] Starting import of ${cashAmount:F0}, duration {countdownDuration:F1}s");
|
||||
|
||||
// 1. Freeze player movement
|
||||
FreezeCarrier(carrier, true);
|
||||
|
||||
// 2. Focus camera
|
||||
yield return StartCoroutine(FocusCamera(carrier.transform, _spawnedCashBox.transform));
|
||||
|
||||
// 3. Show UI
|
||||
ShowImportUI(cashAmount, countdownDuration);
|
||||
|
||||
// 4. Play open animation (reverse of close)
|
||||
yield return StartCoroutine(PlayAnimationReverse(closeAnimationName, openAnimationDuration));
|
||||
PlaySound(openSound);
|
||||
|
||||
// 5. Countdown with cash counting sound
|
||||
yield return StartCoroutine(CountdownRoutine(cashAmount, countdownDuration));
|
||||
|
||||
// 6. Play close animation (forward)
|
||||
yield return StartCoroutine(PlayAnimationForward(closeAnimationName, closeAnimationDuration));
|
||||
PlaySound(closeSound);
|
||||
|
||||
// 7. Deposit the cash to vault
|
||||
if (_vault != null && carrier != null)
|
||||
{
|
||||
float deposited = carrier.DepositToVault(_vault);
|
||||
Debug.Log($"[CashBoxImportController] Deposited ${deposited:F0} to vault. Carrier now has ${carrier.CarriedCash:F0}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"[CashBoxImportController] Deposit FAILED — vault: {_vault}, carrier: {carrier}");
|
||||
}
|
||||
|
||||
// Force-clear any remaining carried cash display
|
||||
// DepositToVault clears bags, but fire event again as safety
|
||||
GameEvents.FireCashDeposited(
|
||||
carrier != null ? carrier.TeamTag : "Player",
|
||||
cashAmount, 1);
|
||||
|
||||
PlaySound(completeSound);
|
||||
|
||||
// 8. Hide UI
|
||||
HideImportUI();
|
||||
|
||||
// 9. Restore camera
|
||||
yield return StartCoroutine(RestoreCamera());
|
||||
|
||||
// 10. Unfreeze player
|
||||
FreezeCarrier(carrier, false);
|
||||
|
||||
_isImporting = false;
|
||||
_currentCarrier = null;
|
||||
|
||||
Debug.Log($"[CashBoxImportController] Import complete!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play animation in reverse by stepping normalizedTime from 1 to 0.
|
||||
/// </summary>
|
||||
private IEnumerator PlayAnimationReverse(string animName, float duration)
|
||||
{
|
||||
if (_cashBoxAnimator == null) yield break;
|
||||
|
||||
float elapsed = 0f;
|
||||
|
||||
// Start at end of animation (normalizedTime = 1)
|
||||
_cashBoxAnimator.Play(animName, 0, 1f);
|
||||
_cashBoxAnimator.speed = 0f; // Pause normal playback
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float normalizedTime = 1f - (elapsed / duration); // Go from 1 to 0
|
||||
normalizedTime = Mathf.Clamp01(normalizedTime);
|
||||
|
||||
// Continuously set the animation time
|
||||
_cashBoxAnimator.Play(animName, 0, normalizedTime);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Ensure we end at time 0 (fully open)
|
||||
_cashBoxAnimator.Play(animName, 0, 0f);
|
||||
_cashBoxAnimator.speed = 1f; // Restore normal speed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Play animation forward normally.
|
||||
/// </summary>
|
||||
private IEnumerator PlayAnimationForward(string animName, float duration)
|
||||
{
|
||||
if (_cashBoxAnimator == null) yield break;
|
||||
|
||||
// Play from start
|
||||
_cashBoxAnimator.speed = 1f;
|
||||
_cashBoxAnimator.Play(animName, 0, 0f);
|
||||
|
||||
yield return new WaitForSeconds(duration);
|
||||
}
|
||||
|
||||
private IEnumerator FocusCamera(Transform playerTransform, Transform boxTransform)
|
||||
{
|
||||
if (_mainCamera == null) yield break;
|
||||
|
||||
// Store original camera state
|
||||
_originalCameraPosition = _mainCamera.transform.position;
|
||||
_originalCameraRotation = _mainCamera.transform.rotation;
|
||||
_originalCameraParent = _mainCamera.transform.parent;
|
||||
|
||||
// Detach camera temporarily
|
||||
_mainCamera.transform.SetParent(null);
|
||||
|
||||
// Calculate target position (between player and box, offset back)
|
||||
Vector3 midPoint = (playerTransform.position + boxTransform.position) / 2f;
|
||||
Vector3 dirToBox = (boxTransform.position - playerTransform.position).normalized;
|
||||
Vector3 sideDir = Vector3.Cross(dirToBox, Vector3.up).normalized;
|
||||
|
||||
Vector3 targetPos = midPoint + sideDir * cameraFocusDistance + Vector3.up * cameraFocusHeight;
|
||||
Quaternion targetRot = Quaternion.LookRotation(midPoint - targetPos);
|
||||
|
||||
// Smooth move camera
|
||||
float elapsed = 0f;
|
||||
float focusDuration = 0.5f;
|
||||
|
||||
while (elapsed < focusDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / focusDuration;
|
||||
t = t * t * (3f - 2f * t); // Smoothstep
|
||||
|
||||
_mainCamera.transform.position = Vector3.Lerp(_originalCameraPosition, targetPos, t);
|
||||
_mainCamera.transform.rotation = Quaternion.Slerp(_originalCameraRotation, targetRot, t);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator RestoreCamera()
|
||||
{
|
||||
if (_mainCamera == null) yield break;
|
||||
|
||||
Vector3 currentPos = _mainCamera.transform.position;
|
||||
Quaternion currentRot = _mainCamera.transform.rotation;
|
||||
|
||||
float elapsed = 0f;
|
||||
float restoreDuration = 0.5f;
|
||||
|
||||
while (elapsed < restoreDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / restoreDuration;
|
||||
t = t * t * (3f - 2f * t); // Smoothstep
|
||||
|
||||
_mainCamera.transform.position = Vector3.Lerp(currentPos, _originalCameraPosition, t);
|
||||
_mainCamera.transform.rotation = Quaternion.Slerp(currentRot, _originalCameraRotation, t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Re-parent camera
|
||||
if (_originalCameraParent != null)
|
||||
_mainCamera.transform.SetParent(_originalCameraParent);
|
||||
}
|
||||
|
||||
private IEnumerator CountdownRoutine(float totalAmount, float duration)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
|
||||
// Start counting sound loop
|
||||
if (countingSound != null)
|
||||
{
|
||||
_audioSource.clip = countingSound;
|
||||
_audioSource.loop = true;
|
||||
_audioSource.volume = soundVolume * 0.7f;
|
||||
_audioSource.Play();
|
||||
}
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float progress = elapsed / duration;
|
||||
float remaining = duration - elapsed;
|
||||
float countedAmount = totalAmount * progress;
|
||||
|
||||
// Update UI
|
||||
UpdateImportUI(remaining, countedAmount, totalAmount, progress);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Stop counting sound
|
||||
if (_audioSource.isPlaying && _audioSource.clip == countingSound)
|
||||
_audioSource.Stop();
|
||||
|
||||
// Final UI update
|
||||
UpdateImportUI(0f, totalAmount, totalAmount, 1f);
|
||||
}
|
||||
|
||||
// Saved component states before freeze (restore exactly on unfreeze)
|
||||
private bool _carrierWasKinematic;
|
||||
private bool _aiWasEnabled;
|
||||
private bool _playerScriptWasEnabled;
|
||||
private bool _navAgentWasEnabled;
|
||||
private bool _navAgentWasStopped;
|
||||
private bool _cashAIWasEnabled;
|
||||
private bool _playerMovementWasEnabled;
|
||||
|
||||
private void FreezeCarrier(CashCarrier carrier, bool freeze)
|
||||
{
|
||||
if (carrier == null) return;
|
||||
|
||||
// CharacterAIController
|
||||
var aiController = carrier.GetComponent<CharacterAIController>();
|
||||
// PlayerScript
|
||||
var playerScript = carrier.GetComponent<PlayerScript>();
|
||||
// PlayerMovementBehaviour
|
||||
var playerMovement = carrier.GetComponent<PlayerMovementBehaviour>();
|
||||
// NavMeshAgent
|
||||
var navAgent = carrier.GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
// CashSystemAI
|
||||
var cashAI = carrier.GetComponent<CashSystemAI>();
|
||||
// Rigidbody
|
||||
var rb = carrier.GetComponent<Rigidbody>();
|
||||
|
||||
if (freeze)
|
||||
{
|
||||
// ─── SAVE original states BEFORE disabling ───
|
||||
_aiWasEnabled = aiController != null && aiController.enabled;
|
||||
_playerScriptWasEnabled = playerScript != null && playerScript.enabled;
|
||||
_playerMovementWasEnabled = playerMovement != null && playerMovement.enabled;
|
||||
_navAgentWasEnabled = navAgent != null && navAgent.enabled;
|
||||
_navAgentWasStopped = navAgent != null && navAgent.enabled && navAgent.isStopped;
|
||||
_cashAIWasEnabled = cashAI != null && cashAI.enabled;
|
||||
|
||||
// Disable everything
|
||||
if (aiController != null) aiController.enabled = false;
|
||||
if (playerScript != null) playerScript.enabled = false;
|
||||
if (playerMovement != null) playerMovement.enabled = false;
|
||||
if (cashAI != null) cashAI.enabled = false;
|
||||
if (navAgent != null && navAgent.enabled) navAgent.isStopped = true;
|
||||
|
||||
if (rb != null)
|
||||
{
|
||||
_carrierWasKinematic = rb.isKinematic;
|
||||
rb.isKinematic = true;
|
||||
rb.linearVelocity = Vector3.zero;
|
||||
rb.angularVelocity = Vector3.zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// ─── RESTORE exactly the original states ───
|
||||
// Only re-enable components that were enabled BEFORE freeze
|
||||
if (aiController != null) aiController.enabled = _aiWasEnabled;
|
||||
if (playerScript != null) playerScript.enabled = _playerScriptWasEnabled;
|
||||
if (playerMovement != null) playerMovement.enabled = _playerMovementWasEnabled;
|
||||
if (cashAI != null) cashAI.enabled = _cashAIWasEnabled;
|
||||
|
||||
if (navAgent != null)
|
||||
{
|
||||
// Only touch the agent if it was enabled before freeze
|
||||
if (_navAgentWasEnabled && navAgent.enabled)
|
||||
navAgent.isStopped = _navAgentWasStopped;
|
||||
}
|
||||
|
||||
if (rb != null)
|
||||
{
|
||||
rb.isKinematic = _carrierWasKinematic;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[CashBoxImportController] Carrier movement {(freeze ? "frozen" : "restored")}");
|
||||
}
|
||||
|
||||
|
||||
private void PlaySound(AudioClip clip)
|
||||
{
|
||||
if (clip == null || _audioSource == null) return;
|
||||
_audioSource.PlayOneShot(clip, soundVolume);
|
||||
}
|
||||
|
||||
// ─── UI Management ───────────────────────────────────────
|
||||
|
||||
private void CreateImportUI()
|
||||
{
|
||||
// Create a simple runtime UI panel
|
||||
Canvas canvas = FindObjectOfType<Canvas>();
|
||||
if (canvas == null)
|
||||
{
|
||||
GameObject canvasObj = new GameObject("ImportUICanvas");
|
||||
canvas = canvasObj.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvasObj.AddComponent<CanvasScaler>();
|
||||
canvasObj.AddComponent<GraphicRaycaster>();
|
||||
}
|
||||
|
||||
// Create panel
|
||||
importUIPanel = new GameObject("ImportUIPanel");
|
||||
importUIPanel.transform.SetParent(canvas.transform, false);
|
||||
|
||||
RectTransform panelRect = importUIPanel.AddComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
panelRect.sizeDelta = new Vector2(400f, 150f);
|
||||
panelRect.anchoredPosition = new Vector2(0f, 100f);
|
||||
|
||||
Image panelBg = importUIPanel.AddComponent<Image>();
|
||||
panelBg.color = new Color(0f, 0f, 0f, 0.8f);
|
||||
|
||||
// Create countdown text
|
||||
GameObject countdownObj = new GameObject("CountdownText");
|
||||
countdownObj.transform.SetParent(importUIPanel.transform, false);
|
||||
RectTransform countdownRect = countdownObj.AddComponent<RectTransform>();
|
||||
countdownRect.anchorMin = new Vector2(0, 0.6f);
|
||||
countdownRect.anchorMax = new Vector2(1, 1f);
|
||||
countdownRect.offsetMin = Vector2.zero;
|
||||
countdownRect.offsetMax = Vector2.zero;
|
||||
|
||||
countdownText = countdownObj.AddComponent<TextMeshProUGUI>();
|
||||
countdownText.text = "IMPORTING...";
|
||||
countdownText.fontSize = 36;
|
||||
countdownText.alignment = TextAlignmentOptions.Center;
|
||||
countdownText.color = Color.white;
|
||||
|
||||
// Create amount text
|
||||
GameObject amountObj = new GameObject("AmountText");
|
||||
amountObj.transform.SetParent(importUIPanel.transform, false);
|
||||
RectTransform amountRect = amountObj.AddComponent<RectTransform>();
|
||||
amountRect.anchorMin = new Vector2(0, 0.3f);
|
||||
amountRect.anchorMax = new Vector2(1, 0.6f);
|
||||
amountRect.offsetMin = Vector2.zero;
|
||||
amountRect.offsetMax = Vector2.zero;
|
||||
|
||||
amountText = amountObj.AddComponent<TextMeshProUGUI>();
|
||||
amountText.text = "$0";
|
||||
amountText.fontSize = 28;
|
||||
amountText.alignment = TextAlignmentOptions.Center;
|
||||
amountText.color = new Color(0.2f, 1f, 0.2f);
|
||||
|
||||
// Create circular progress dial (radial Image)
|
||||
GameObject dialObj = new GameObject("ProgressDial");
|
||||
dialObj.transform.SetParent(importUIPanel.transform, false);
|
||||
RectTransform dialRect = dialObj.AddComponent<RectTransform>();
|
||||
dialRect.anchorMin = new Vector2(0.35f, 0.05f);
|
||||
dialRect.anchorMax = new Vector2(0.65f, 0.35f);
|
||||
dialRect.offsetMin = Vector2.zero;
|
||||
dialRect.offsetMax = Vector2.zero;
|
||||
|
||||
progressDial = dialObj.AddComponent<Image>();
|
||||
if (dialSprite != null)
|
||||
progressDial.sprite = dialSprite;
|
||||
progressDial.type = Image.Type.Filled;
|
||||
progressDial.fillMethod = Image.FillMethod.Radial360;
|
||||
progressDial.fillOrigin = (int)Image.Origin360.Top;
|
||||
progressDial.fillClockwise = true;
|
||||
progressDial.fillAmount = 0f;
|
||||
progressDial.preserveAspect = true;
|
||||
|
||||
importUIPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void ShowImportUI(float totalAmount, float duration)
|
||||
{
|
||||
if (importUIPanel != null)
|
||||
importUIPanel.SetActive(true);
|
||||
|
||||
UpdateImportUI(duration, 0f, totalAmount, 0f);
|
||||
}
|
||||
|
||||
private void HideImportUI()
|
||||
{
|
||||
if (importUIPanel != null)
|
||||
importUIPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void UpdateImportUI(float remaining, float counted, float total, float progress)
|
||||
{
|
||||
// Timer text: only the seconds number
|
||||
if (countdownText != null)
|
||||
countdownText.text = remaining > 0 ? $"{remaining:F1}" : "0";
|
||||
|
||||
// Status text: label on a separate TMP
|
||||
if (statusText != null)
|
||||
statusText.text = remaining > 0 ? "IMPORTING" : "COMPLETE!";
|
||||
|
||||
if (amountText != null)
|
||||
amountText.text = $"${counted:F0} / ${total:F0}";
|
||||
|
||||
if (progressDial != null)
|
||||
progressDial.fillAmount = Mathf.Clamp01(progress);
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Check if a carrier can start importing (not already importing).
|
||||
/// </summary>
|
||||
public bool CanImport(CashCarrier carrier)
|
||||
{
|
||||
return !_isImporting && carrier != null && carrier.CarriedCash > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel current import (emergency use only).
|
||||
/// </summary>
|
||||
public void CancelImport()
|
||||
{
|
||||
if (!_isImporting) return;
|
||||
|
||||
StopAllCoroutines();
|
||||
|
||||
if (_currentCarrier != null)
|
||||
FreezeCarrier(_currentCarrier, false);
|
||||
|
||||
HideImportUI();
|
||||
_isImporting = false;
|
||||
_currentCarrier = null;
|
||||
|
||||
// Restore camera immediately
|
||||
if (_mainCamera != null && _originalCameraParent != null)
|
||||
{
|
||||
_mainCamera.transform.SetParent(_originalCameraParent);
|
||||
_mainCamera.transform.position = _originalCameraPosition;
|
||||
_mainCamera.transform.rotation = _originalCameraRotation;
|
||||
}
|
||||
|
||||
Debug.Log("[CashBoxImportController] Import cancelled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2bc8982e2d3d6af29a9b51c9542690bd
|
||||
390
Assets/Scripts/CashSystem/CashCarrier.cs
Normal file
390
Assets/Scripts/CashSystem/CashCarrier.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// CashCarrier — Attach to characters to track carried cash bags.
|
||||
/// Tracks actual CashBag references (not just float), handles stacking
|
||||
/// speed penalty, combo multiplier on deposit, death drops.
|
||||
/// Uses GameEvents instead of FindObjectOfType.
|
||||
/// </summary>
|
||||
public class CashCarrier : MonoBehaviour
|
||||
{
|
||||
[Header("Carry Settings")]
|
||||
[SerializeField] private int maxBags = 3;
|
||||
|
||||
[Header("Movement Penalty")]
|
||||
[SerializeField] private float singleBagPenalty = 0.15f; // -15% speed per bag
|
||||
[SerializeField] private float multiBagExtraPenalty = 0.10f; // Additional -10% for 2+ bags
|
||||
[SerializeField] private float maxSpeedReduction = 0.40f; // Never slower than 60% speed
|
||||
|
||||
[Header("Death Drop")]
|
||||
[SerializeField] private float cashDropPercent = 1.0f; // Drop 100% on death (all bags scatter)
|
||||
[SerializeField] private GameObject droppedCashPrefab; // Optional: legacy prefab for dropped cash
|
||||
|
||||
[Header("Visual Indicators")]
|
||||
[SerializeField] private GameObject carryingIndicator;
|
||||
[SerializeField] private ParticleSystem cashParticles;
|
||||
|
||||
[Header("Events")]
|
||||
public UnityEvent<float> OnCashChanged;
|
||||
public UnityEvent<float> OnCashDropped;
|
||||
|
||||
// ─── Runtime ─────────────────────────────────────────────
|
||||
private readonly List<CashBag> _carriedBags = new List<CashBag>(4);
|
||||
private float _legacyCash = 0f; // Backwards compat: cash from old-style AddCash(float)
|
||||
private float _originalSpeed = -1f;
|
||||
private CharacterAIController _aiController;
|
||||
private HealthNew _health;
|
||||
private Animator _animator;
|
||||
private bool _isDead = false;
|
||||
|
||||
// ─── Combo Tracking ──────────────────────────────────────
|
||||
private float _lastDepositTime;
|
||||
private int _comboCount;
|
||||
private const float COMBO_WINDOW = 10f; // Seconds between deposits to maintain combo
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────
|
||||
public int BagCount => _carriedBags.Count;
|
||||
public int BoxCount => BagCount; // Legacy alias
|
||||
public float TotalValue => CarriedCash; // Alias for AI compatibility
|
||||
public float CarriedCash
|
||||
{
|
||||
get
|
||||
{
|
||||
float total = _legacyCash;
|
||||
for (int i = 0; i < _carriedBags.Count; i++)
|
||||
if (_carriedBags[i] != null) total += _carriedBags[i].GetEffectiveValue();
|
||||
return total;
|
||||
}
|
||||
}
|
||||
public bool IsCarrying => _carriedBags.Count > 0 || _legacyCash > 0;
|
||||
public string TeamTag => gameObject.tag;
|
||||
public float SpeedMultiplier { get; private set; } = 1f;
|
||||
public int ComboCount => _comboCount;
|
||||
public List<CashBag> CarriedBags => _carriedBags;
|
||||
public List<CashBag> CarriedBoxes => _carriedBags; // Legacy alias
|
||||
|
||||
/// <summary>True when this character is carrying an ExtractionVault (managed by ExtractionVault, not CashCarrier).</summary>
|
||||
public bool IsCarryingVault
|
||||
{
|
||||
get
|
||||
{
|
||||
var vaults = EntityRegistry<ExtractionVault>.GetAll();
|
||||
for (int i = 0; i < vaults.Count; i++)
|
||||
if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == gameObject)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EntityRegistry<CashCarrier>.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EntityRegistry<CashCarrier>.Unregister(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Explicit reset — guarantees 0 cash on every spawn/reuse ──
|
||||
_carriedBags.Clear();
|
||||
_legacyCash = 0f;
|
||||
_isDead = false;
|
||||
|
||||
_aiController = GetComponent<CharacterAIController>();
|
||||
_health = GetComponent<HealthNew>();
|
||||
_animator = GetComponent<Animator>();
|
||||
|
||||
if (_aiController != null)
|
||||
_originalSpeed = _aiController.GetSpeed();
|
||||
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(0f); // Tell listeners we start at zero
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Death check
|
||||
if (_health != null && _health.isDead && !_isDead)
|
||||
{
|
||||
_isDead = true;
|
||||
OnDeath();
|
||||
}
|
||||
else if (_health != null && !_health.isDead)
|
||||
{
|
||||
_isDead = false;
|
||||
}
|
||||
|
||||
ApplyMovementPenalty();
|
||||
}
|
||||
|
||||
// ─── Bag Management ─────────────────────────────────────
|
||||
|
||||
/// <summary>Can this carrier pick up more bags?</summary>
|
||||
public bool CanPickUp() => _carriedBags.Count < maxBags && !_isDead;
|
||||
|
||||
/// <summary>Called by CashBag when picked up.</summary>
|
||||
public void OnBagPickedUp(CashBag bag)
|
||||
{
|
||||
if (bag == null || _carriedBags.Contains(bag)) return;
|
||||
|
||||
_carriedBags.Add(bag);
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(CarriedCash);
|
||||
|
||||
GameEvents.FireKillFeedEntry(gameObject.name, "picked up", $"Cash (${bag.CashValue})");
|
||||
}
|
||||
|
||||
/// <summary>Legacy alias for OnBagPickedUp.</summary>
|
||||
public void OnBoxPickedUp(CashBag bag) => OnBagPickedUp(bag);
|
||||
|
||||
/// <summary>Drop all carried bags.</summary>
|
||||
public void DropAllBags()
|
||||
{
|
||||
if (_carriedBags.Count == 0) return;
|
||||
|
||||
Vector3 dropPos = transform.position;
|
||||
float totalDropped = 0f;
|
||||
|
||||
for (int i = _carriedBags.Count - 1; i >= 0; i--)
|
||||
{
|
||||
CashBag bag = _carriedBags[i];
|
||||
if (bag != null)
|
||||
{
|
||||
// Scatter bags slightly around drop point
|
||||
Vector3 offset = Random.insideUnitSphere * 1.5f;
|
||||
offset.y = 0;
|
||||
bag.Drop(dropPos + offset);
|
||||
totalDropped += bag.CashValue;
|
||||
}
|
||||
}
|
||||
|
||||
_carriedBags.Clear();
|
||||
UpdateVisuals();
|
||||
OnCashDropped?.Invoke(totalDropped);
|
||||
OnCashChanged?.Invoke(CarriedCash);
|
||||
}
|
||||
|
||||
/// <summary>Legacy alias for DropAllBags.</summary>
|
||||
public void DropAllBoxes() => DropAllBags();
|
||||
|
||||
/// <summary>Deposit all to vault. Returns total value deposited.</summary>
|
||||
public float DepositToVault(TeamVault vault)
|
||||
{
|
||||
if (vault == null) return 0f;
|
||||
if (_carriedBags.Count == 0 && _legacyCash <= 0) return 0f;
|
||||
|
||||
// Calculate combo
|
||||
if (Time.time - _lastDepositTime <= COMBO_WINDOW)
|
||||
_comboCount++;
|
||||
else
|
||||
_comboCount = 1;
|
||||
_lastDepositTime = Time.time;
|
||||
|
||||
float totalValue = 0f;
|
||||
int comboMultiplier = Mathf.Min(_comboCount, 3); // Cap at 3x
|
||||
|
||||
// Deposit tracked bags
|
||||
for (int i = _carriedBags.Count - 1; i >= 0; i--)
|
||||
{
|
||||
CashBag bag = _carriedBags[i];
|
||||
if (bag != null)
|
||||
{
|
||||
float value = bag.GetEffectiveValue();
|
||||
switch (comboMultiplier)
|
||||
{
|
||||
case 2: value *= 1.5f; break;
|
||||
case 3: value *= 2.0f; break;
|
||||
}
|
||||
totalValue += value;
|
||||
bag.Despawn();
|
||||
}
|
||||
}
|
||||
_carriedBags.Clear();
|
||||
|
||||
// Deposit any legacy cash too
|
||||
if (_legacyCash > 0)
|
||||
{
|
||||
float legacyValue = _legacyCash;
|
||||
switch (comboMultiplier)
|
||||
{
|
||||
case 2: legacyValue *= 1.5f; break;
|
||||
case 3: legacyValue *= 2.0f; break;
|
||||
}
|
||||
totalValue += legacyValue;
|
||||
_legacyCash = 0f;
|
||||
}
|
||||
|
||||
vault.DepositCash(totalValue);
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(0f);
|
||||
|
||||
// Fire events
|
||||
GameEvents.FireCashDeposited(TeamTag, totalValue, comboMultiplier);
|
||||
if (comboMultiplier > 1)
|
||||
GameEvents.FireComboMultiplier(comboMultiplier);
|
||||
|
||||
GameEvents.FireKillFeedEntry(
|
||||
gameObject.name,
|
||||
comboMultiplier > 1 ? $"SCORED {comboMultiplier}x" : "scored",
|
||||
$"${totalValue:F0}"
|
||||
);
|
||||
|
||||
return totalValue;
|
||||
}
|
||||
|
||||
// ─── Legacy API (backwards compat with old CashBag/CashSystemAI) ─
|
||||
|
||||
/// <summary>Add cash directly (legacy float-based API).</summary>
|
||||
public void AddCash(float amount)
|
||||
{
|
||||
_legacyCash += amount;
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(CarriedCash);
|
||||
}
|
||||
|
||||
/// <summary>Remove cash amount (legacy API).</summary>
|
||||
public float RemoveCash(float amount)
|
||||
{
|
||||
float removed = Mathf.Min(amount, _legacyCash);
|
||||
_legacyCash -= removed;
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(CarriedCash);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>Remove all cash and return amount (legacy API).</summary>
|
||||
public float RemoveAllCash()
|
||||
{
|
||||
float removed = CarriedCash;
|
||||
_legacyCash = 0f;
|
||||
_carriedBags.Clear();
|
||||
UpdateVisuals();
|
||||
OnCashChanged?.Invoke(0f);
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ─── Death ───────────────────────────────────────────────
|
||||
|
||||
private void OnDeath()
|
||||
{
|
||||
// Fire character died for respawn system (always, even if not carrying)
|
||||
GameEvents.FireCharacterDied(gameObject);
|
||||
|
||||
if (!IsCarrying) return;
|
||||
|
||||
GameEvents.FireCarrierKilled(this);
|
||||
|
||||
// Drop tracked bags (scatter around)
|
||||
DropAllBags();
|
||||
|
||||
// Drop legacy cash as a physical pickup
|
||||
if (_legacyCash > 0 && droppedCashPrefab != null)
|
||||
{
|
||||
float dropAmount = _legacyCash * cashDropPercent;
|
||||
GameObject dropped = Instantiate(droppedCashPrefab, transform.position + Vector3.up, Quaternion.identity);
|
||||
CashBag droppedBag = dropped.GetComponent<CashBag>();
|
||||
if (droppedBag != null)
|
||||
droppedBag.SetCashValue(dropAmount);
|
||||
_legacyCash -= dropAmount;
|
||||
OnCashDropped?.Invoke(dropAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Movement Penalty ────────────────────────────────────
|
||||
|
||||
private void ApplyMovementPenalty()
|
||||
{
|
||||
int count = _carriedBags.Count;
|
||||
if (count == 0 && _legacyCash <= 0)
|
||||
{
|
||||
SpeedMultiplier = 1f;
|
||||
}
|
||||
else if (count == 0)
|
||||
{
|
||||
// Legacy cash — simple penalty
|
||||
float penaltyFactor = Mathf.Clamp01(_legacyCash / 500f);
|
||||
SpeedMultiplier = 1f - (singleBagPenalty * penaltyFactor);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stacking penalty: -15% per bag, -10% extra for 2+
|
||||
float penalty = singleBagPenalty * count;
|
||||
if (count >= 2)
|
||||
penalty += multiBagExtraPenalty * (count - 1);
|
||||
penalty = Mathf.Min(penalty, maxSpeedReduction);
|
||||
SpeedMultiplier = 1f - penalty;
|
||||
}
|
||||
|
||||
// Apply to AI controller (skip if carrying ExtractionVault — vault manages speed directly)
|
||||
if (_aiController != null && _originalSpeed > 0 && !IsCarryingVault)
|
||||
_aiController.SetSpeed(_originalSpeed * SpeedMultiplier);
|
||||
|
||||
// Apply to animator
|
||||
if (_animator != null)
|
||||
{
|
||||
if (IsCarrying)
|
||||
{
|
||||
float currentSpeed = _animator.GetFloat("Speed");
|
||||
if (currentSpeed > 0) _animator.speed = SpeedMultiplier;
|
||||
}
|
||||
else
|
||||
{
|
||||
_animator.speed = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Visuals ─────────────────────────────────────────────
|
||||
|
||||
private void UpdateVisuals()
|
||||
{
|
||||
bool carrying = IsCarrying;
|
||||
|
||||
if (carryingIndicator != null)
|
||||
carryingIndicator.SetActive(carrying);
|
||||
|
||||
if (cashParticles != null)
|
||||
{
|
||||
if (carrying && !cashParticles.isPlaying)
|
||||
cashParticles.Play();
|
||||
else if (!carrying && cashParticles.isPlaying)
|
||||
cashParticles.Stop(true, ParticleSystemStopBehavior.StopEmitting);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Should AI deposit? (has bags or enough cash)</summary>
|
||||
public bool ShouldDeposit(float threshold = 200f)
|
||||
{
|
||||
return _carriedBags.Count >= 1 || _legacyCash >= threshold;
|
||||
}
|
||||
|
||||
/// <summary>Check if carrying a specific bag.</summary>
|
||||
public bool IsCarryingBag(CashBag bag) => _carriedBags.Contains(bag);
|
||||
|
||||
/// <summary>Legacy alias for IsCarryingBag.</summary>
|
||||
public bool IsCarryingBox(CashBag bag) => IsCarryingBag(bag);
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_originalSpeed >= 0 && _aiController != null)
|
||||
_aiController.SetSpeed(_originalSpeed);
|
||||
if (_animator != null) _animator.speed = 1f;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/CashCarrier.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashCarrier.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf4ee3cf212672b8d96990e29bcb472c
|
||||
2167
Assets/Scripts/CashSystem/CashSystemAI.cs
Normal file
2167
Assets/Scripts/CashSystem/CashSystemAI.cs
Normal file
File diff suppressed because it is too large
Load Diff
740
Assets/Scripts/CashSystem/CashSystemAI.cs.bak
Normal file
740
Assets/Scripts/CashSystem/CashSystemAI.cs.bak
Normal file
@@ -0,0 +1,740 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// CashSystemAI - AI decision layer for Cash System mode.
|
||||
/// Works alongside CharacterAIController for combat.
|
||||
/// </summary>
|
||||
public class CashSystemAI : MonoBehaviour
|
||||
{
|
||||
public enum AIState
|
||||
{
|
||||
Idle,
|
||||
CollectCash,
|
||||
DepositCash,
|
||||
AttackEnemy,
|
||||
StealFromVault,
|
||||
DefendVault,
|
||||
Retreat,
|
||||
Patrol // NEW: wander when nothing to do
|
||||
}
|
||||
|
||||
public enum AIRole
|
||||
{
|
||||
Balanced,
|
||||
Collector,
|
||||
Defender,
|
||||
Aggressor
|
||||
}
|
||||
|
||||
[Header("AI Settings")]
|
||||
[SerializeField] private AIState currentState = AIState.Idle;
|
||||
[SerializeField] private AIRole role = AIRole.Balanced;
|
||||
|
||||
[Header("Decision Weights")]
|
||||
[SerializeField] private float collectWeight = 1f;
|
||||
[SerializeField] private float depositWeight = 1.5f;
|
||||
[SerializeField] private float attackWeight = 1f;
|
||||
[SerializeField] private float stealWeight = 0.8f;
|
||||
[SerializeField] private float defendWeight = 1.2f;
|
||||
|
||||
[Header("Thresholds")]
|
||||
[SerializeField] private float depositCashThreshold = 100f;
|
||||
[SerializeField] private float retreatHealthThreshold = 30f;
|
||||
[SerializeField] private float nearbyEnemyDistance = 15f;
|
||||
[SerializeField] private float nearbyBoxDistance = 50f;
|
||||
|
||||
[Header("Patrol Settings")]
|
||||
[SerializeField] private float patrolRadius = 12f;
|
||||
[SerializeField] private float patrolPauseMin = 1f;
|
||||
[SerializeField] private float patrolPauseMax = 3f;
|
||||
|
||||
[Header("Timing")]
|
||||
[SerializeField] private float decisionInterval = 0.3f;
|
||||
[SerializeField] private float stateMinDuration = 0.8f;
|
||||
[Header("Difficulty")]
|
||||
[SerializeField] private int aiLevel = 1; // 1 = normal, higher = harder/faster
|
||||
|
||||
// Components
|
||||
private CashCarrier cashCarrier;
|
||||
private HealthNew health;
|
||||
private CharacterAIController aiController;
|
||||
private PlayerScript playerScript;
|
||||
|
||||
// Runtime state
|
||||
private float lastDecisionTime;
|
||||
private float stateStartTime;
|
||||
private Transform currentTarget;
|
||||
private TeamVault myVault;
|
||||
private TeamVault enemyVault;
|
||||
private string myTeamTag;
|
||||
|
||||
// Patrol state
|
||||
private Vector3 patrolDestination;
|
||||
private bool hasPatrolDestination;
|
||||
private float patrolPauseEndTime;
|
||||
private NavMeshAgent navAgent;
|
||||
|
||||
public AIState CurrentState => currentState;
|
||||
public AIRole CurrentRole => role;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Only activate in Cash System mode
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get components
|
||||
cashCarrier = GetComponent<CashCarrier>();
|
||||
health = GetComponent<HealthNew>();
|
||||
aiController = GetComponent<CharacterAIController>();
|
||||
playerScript = GetComponent<PlayerScript>();
|
||||
navAgent = GetComponent<NavMeshAgent>();
|
||||
|
||||
myTeamTag = gameObject.tag;
|
||||
|
||||
// Assign random role
|
||||
AssignRole();
|
||||
|
||||
// Apply simple difficulty scaling
|
||||
if (aiLevel > 1)
|
||||
{
|
||||
float scale = 1f + (aiLevel - 1) * 0.15f;
|
||||
collectWeight *= scale;
|
||||
depositWeight *= scale;
|
||||
attackWeight *= scale;
|
||||
stealWeight *= scale;
|
||||
defendWeight *= scale;
|
||||
decisionInterval = Mathf.Max(0.2f, decisionInterval / (1f + (aiLevel - 1) * 0.2f));
|
||||
|
||||
if (navAgent != null)
|
||||
{
|
||||
navAgent.speed *= 1f + (aiLevel - 1) * 0.1f;
|
||||
}
|
||||
}
|
||||
|
||||
// Find vaults
|
||||
FindVaults();
|
||||
|
||||
Debug.Log($"[CashSystemAI] Initialized on {gameObject.name}, Team: {myTeamTag}, Role: {role}");
|
||||
}
|
||||
|
||||
private void AssignRole()
|
||||
{
|
||||
// Random role assignment with some bias based on character
|
||||
float rand = Random.value;
|
||||
if (rand < 0.4f) role = AIRole.Balanced;
|
||||
else if (rand < 0.65f) role = AIRole.Collector;
|
||||
else if (rand < 0.85f) role = AIRole.Defender;
|
||||
else role = AIRole.Aggressor;
|
||||
|
||||
// Adjust weights based on role
|
||||
switch (role)
|
||||
{
|
||||
case AIRole.Collector:
|
||||
collectWeight *= 1.5f;
|
||||
depositWeight *= 1.3f;
|
||||
attackWeight *= 0.7f;
|
||||
break;
|
||||
case AIRole.Defender:
|
||||
defendWeight *= 2f;
|
||||
attackWeight *= 1.2f;
|
||||
collectWeight *= 0.6f;
|
||||
break;
|
||||
case AIRole.Aggressor:
|
||||
attackWeight *= 2f;
|
||||
stealWeight *= 1.5f;
|
||||
collectWeight *= 0.5f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void FindVaults()
|
||||
{
|
||||
// Use EntityRegistry (zero-alloc) instead of FindObjectsOfType
|
||||
var vaults = EntityRegistry<TeamVault>.GetAll();
|
||||
for (int i = 0; i < vaults.Count; i++)
|
||||
{
|
||||
if (vaults[i] == null) continue;
|
||||
if (vaults[i].TeamTag == myTeamTag)
|
||||
myVault = vaults[i];
|
||||
else
|
||||
enemyVault = vaults[i];
|
||||
}
|
||||
|
||||
// Fallback if EntityRegistry empty (vaults not yet registered)
|
||||
if (myVault == null || enemyVault == null)
|
||||
{
|
||||
TeamVault[] fallback = FindObjectsOfType<TeamVault>();
|
||||
foreach (TeamVault vault in fallback)
|
||||
{
|
||||
if (vault.TeamTag == myTeamTag) myVault = vault;
|
||||
else enemyVault = vault;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.time - lastDecisionTime < decisionInterval)
|
||||
return;
|
||||
|
||||
lastDecisionTime = Time.time;
|
||||
|
||||
// Lazy re-find vaults if they weren't found at Start
|
||||
if (myVault == null || enemyVault == null)
|
||||
FindVaults();
|
||||
|
||||
// Check health for retreat (only when combat enabled)
|
||||
if (IsCombatEnabled() && health != null && health.currentHealth < retreatHealthThreshold)
|
||||
{
|
||||
SetState(AIState.Retreat);
|
||||
ExecuteState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't switch state too quickly
|
||||
if (Time.time - stateStartTime < stateMinDuration && currentState != AIState.Idle)
|
||||
{
|
||||
// But always re-execute current state so movement continues
|
||||
ExecuteState();
|
||||
return;
|
||||
}
|
||||
|
||||
MakeDecision();
|
||||
ExecuteState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this AI is allowed to take combat actions (after countdown)
|
||||
/// </summary>
|
||||
private bool IsCombatEnabled()
|
||||
{
|
||||
// Check CharacterAIController's attack flag
|
||||
if (aiController != null)
|
||||
{
|
||||
return aiController.attack;
|
||||
}
|
||||
|
||||
// Check PlayerScript's attack flag (for AI teammates)
|
||||
if (playerScript != null)
|
||||
{
|
||||
return playerScript.attack;
|
||||
}
|
||||
|
||||
// Default to true if no scripts found
|
||||
return true;
|
||||
}
|
||||
|
||||
private void MakeDecision()
|
||||
{
|
||||
float bestScore = 0f;
|
||||
AIState bestState = AIState.Idle;
|
||||
|
||||
bool isCarrying = cashCarrier != null && cashCarrier.CarriedCash >= 0.01f;
|
||||
|
||||
// Evaluate: Deposit (if carrying ANY cash and vault exists)
|
||||
if (isCarrying && myVault != null)
|
||||
{
|
||||
// Higher urgency the more cash we carry
|
||||
float score = depositWeight * Mathf.Max(1f, cashCarrier.CarriedCash / depositCashThreshold);
|
||||
// Extra urgency if carrying max bags
|
||||
if (cashCarrier.BagCount >= 3) score *= 2f;
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestState = AIState.DepositCash;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate: Defend (if vault under attack)
|
||||
if (myVault != null && myVault.IsBeingStolen)
|
||||
{
|
||||
float score = defendWeight * 3f; // High priority
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestState = AIState.DefendVault;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate: Attack (if enemy nearby AND combat enabled)
|
||||
if (IsCombatEnabled())
|
||||
{
|
||||
GameObject nearestEnemy = FindNearestEnemy();
|
||||
if (nearestEnemy != null)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, nearestEnemy.transform.position);
|
||||
if (dist < nearbyEnemyDistance)
|
||||
{
|
||||
float score = attackWeight * (1f - dist / nearbyEnemyDistance);
|
||||
|
||||
// Higher score if enemy is carrying cash
|
||||
CashCarrier enemyCarrier = nearestEnemy.GetComponent<CashCarrier>();
|
||||
if (enemyCarrier != null && enemyCarrier.CarriedCash > 0)
|
||||
{
|
||||
score *= 1.5f;
|
||||
}
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestState = AIState.AttackEnemy;
|
||||
currentTarget = nearestEnemy.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate: Collect (if not carrying max bags and bag available)
|
||||
if (cashCarrier != null && cashCarrier.CanPickUp())
|
||||
{
|
||||
CashBag nearestBag = FindNearestCashBag();
|
||||
if (nearestBag != null)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, nearestBag.transform.position);
|
||||
// Always consider collecting if we can carry more
|
||||
float score = collectWeight * (1f + nearestBag.CashValue / 100f);
|
||||
// Slight distance penalty (prefer closer bags) but don't disqualify far bags
|
||||
score *= Mathf.Clamp01(1f - dist / (nearbyBoxDistance * 2f)) + 0.5f;
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestState = AIState.CollectCash;
|
||||
currentTarget = nearestBag.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate: Steal (if enemy vault has cash and we're close and combat enabled)
|
||||
if (IsCombatEnabled() && enemyVault != null && enemyVault.StoredCash > 0 && !isCarrying)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, enemyVault.transform.position);
|
||||
if (dist < nearbyBoxDistance)
|
||||
{
|
||||
float score = stealWeight * (enemyVault.StoredCash / 500f);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestState = AIState.StealFromVault;
|
||||
currentTarget = enemyVault.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Patrol if nothing else to do (NEVER stay idle)
|
||||
if (bestState == AIState.Idle)
|
||||
{
|
||||
CashBag fallbackBag = FindNearestCashBag();
|
||||
if (fallbackBag != null)
|
||||
{
|
||||
bestState = AIState.CollectCash;
|
||||
currentTarget = fallbackBag.transform;
|
||||
}
|
||||
else
|
||||
{
|
||||
bestState = AIState.Patrol;
|
||||
}
|
||||
}
|
||||
|
||||
SetState(bestState);
|
||||
}
|
||||
|
||||
private void SetState(AIState newState)
|
||||
{
|
||||
if (newState != currentState)
|
||||
{
|
||||
currentState = newState;
|
||||
stateStartTime = Time.time;
|
||||
Debug.Log($"[CashSystemAI] {gameObject.name} -> {newState}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteState()
|
||||
{
|
||||
switch (currentState)
|
||||
{
|
||||
case AIState.Idle:
|
||||
ExecuteIdle();
|
||||
break;
|
||||
case AIState.CollectCash:
|
||||
ExecuteCollect();
|
||||
break;
|
||||
case AIState.DepositCash:
|
||||
ExecuteDeposit();
|
||||
break;
|
||||
case AIState.AttackEnemy:
|
||||
ExecuteAttack();
|
||||
break;
|
||||
case AIState.StealFromVault:
|
||||
ExecuteSteal();
|
||||
break;
|
||||
case AIState.DefendVault:
|
||||
ExecuteDefend();
|
||||
break;
|
||||
case AIState.Retreat:
|
||||
ExecuteRetreat();
|
||||
break;
|
||||
case AIState.Patrol:
|
||||
ExecutePatrol();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteIdle()
|
||||
{
|
||||
// Try to find something to do
|
||||
CashBag nearestBag = FindNearestCashBag();
|
||||
if (nearestBag != null)
|
||||
{
|
||||
currentTarget = nearestBag.transform;
|
||||
SetState(AIState.CollectCash);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nothing to do — patrol instead of standing still
|
||||
SetState(AIState.Patrol);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTA-style patrol: pick random NavMesh point, walk there, pause briefly, repeat.
|
||||
/// This ensures AI NEVER stands completely still.
|
||||
/// </summary>
|
||||
private void ExecutePatrol()
|
||||
{
|
||||
// If pausing at a patrol point, wait
|
||||
if (Time.time < patrolPauseEndTime)
|
||||
return;
|
||||
|
||||
// Check if we should switch to a real task
|
||||
CashBag nearestBag = FindNearestCashBag();
|
||||
if (nearestBag != null)
|
||||
{
|
||||
currentTarget = nearestBag.transform;
|
||||
SetState(AIState.CollectCash);
|
||||
hasPatrolDestination = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for nearby enemies while patrolling
|
||||
GameObject nearestEnemy = FindNearestEnemy();
|
||||
if (nearestEnemy != null)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, nearestEnemy.transform.position);
|
||||
if (dist < nearbyEnemyDistance * 0.5f) // React at half range while patrolling
|
||||
{
|
||||
currentTarget = nearestEnemy.transform;
|
||||
SetState(AIState.AttackEnemy);
|
||||
hasPatrolDestination = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a new patrol destination if needed
|
||||
if (!hasPatrolDestination || HasReachedDestination())
|
||||
{
|
||||
if (hasPatrolDestination)
|
||||
{
|
||||
// Just arrived — pause briefly before picking a new point
|
||||
patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax);
|
||||
hasPatrolDestination = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick random NavMesh point
|
||||
Vector3 randomPoint = transform.position + Random.insideUnitSphere * patrolRadius;
|
||||
randomPoint.y = transform.position.y;
|
||||
|
||||
NavMeshHit hit;
|
||||
if (NavMesh.SamplePosition(randomPoint, out hit, patrolRadius, NavMesh.AllAreas))
|
||||
{
|
||||
patrolDestination = hit.position;
|
||||
hasPatrolDestination = true;
|
||||
MoveToward(patrolDestination);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Continue moving toward current patrol point
|
||||
MoveToward(patrolDestination);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasReachedDestination()
|
||||
{
|
||||
if (!hasPatrolDestination) return true;
|
||||
float dist = Vector3.Distance(transform.position, patrolDestination);
|
||||
return dist < 2f;
|
||||
}
|
||||
|
||||
private void ExecuteCollect()
|
||||
{
|
||||
if (currentTarget == null)
|
||||
{
|
||||
// Target gone — find another bag
|
||||
CashBag newBag = FindNearestCashBag();
|
||||
if (newBag != null)
|
||||
{
|
||||
currentTarget = newBag.transform;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetState(AIState.Patrol);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate target is still an available bag
|
||||
var bag = currentTarget.GetComponent<CashBag>();
|
||||
if (bag == null || !bag.IsAvailable)
|
||||
{
|
||||
// Bag was taken or despawned — find another
|
||||
CashBag newBag = FindNearestCashBag();
|
||||
if (newBag != null)
|
||||
{
|
||||
currentTarget = newBag.transform;
|
||||
bag = newBag;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentTarget = null;
|
||||
SetState(AIState.Patrol);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
float dist = Vector3.Distance(transform.position, currentTarget.position);
|
||||
|
||||
// If we're close enough to the bag, attempt direct pickup
|
||||
if (dist <= 2.5f)
|
||||
{
|
||||
if (cashCarrier != null && cashCarrier.CanPickUp())
|
||||
{
|
||||
bag.PickUp(cashCarrier);
|
||||
cashCarrier.OnBagPickedUp(bag);
|
||||
|
||||
Debug.Log($"[CashSystemAI] {gameObject.name} picked up bag (${bag.CashValue}), now carrying ${cashCarrier.CarriedCash:F0}");
|
||||
|
||||
// After pickup, immediately decide next action
|
||||
if (cashCarrier.ShouldDeposit() && myVault != null)
|
||||
SetState(AIState.DepositCash);
|
||||
else
|
||||
{
|
||||
// Try to find another bag
|
||||
CashBag nextBag = FindNearestCashBag();
|
||||
if (nextBag != null && cashCarrier.CanPickUp())
|
||||
{
|
||||
currentTarget = nextBag.transform;
|
||||
SetState(AIState.CollectCash);
|
||||
}
|
||||
else
|
||||
SetState(AIState.DepositCash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
MoveToward(currentTarget.position);
|
||||
}
|
||||
|
||||
private void ExecuteDeposit()
|
||||
{
|
||||
if (myVault == null)
|
||||
{
|
||||
FindVaults();
|
||||
if (myVault == null)
|
||||
{
|
||||
SetState(AIState.Patrol);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we still have cash to deposit
|
||||
if (cashCarrier == null || cashCarrier.CarriedCash < 0.01f)
|
||||
{
|
||||
Debug.Log($"[CashSystemAI] {gameObject.name} has no cash to deposit, switching to collect");
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
float dist = Vector3.Distance(transform.position, myVault.transform.position);
|
||||
|
||||
// If close enough to vault, deposit directly
|
||||
if (dist <= 3f)
|
||||
{
|
||||
float deposited = cashCarrier.DepositToVault(myVault);
|
||||
Debug.Log($"[CashSystemAI] {gameObject.name} deposited ${deposited:F0} to vault!");
|
||||
|
||||
// Go find more bags after depositing
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toward vault
|
||||
MoveToward(myVault.transform.position);
|
||||
}
|
||||
|
||||
private void ExecuteAttack()
|
||||
{
|
||||
if (currentTarget == null)
|
||||
{
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Let CharacterAIController handle combat
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.SetTarget(currentTarget.gameObject);
|
||||
aiController.NavigateTo(currentTarget.position);
|
||||
}
|
||||
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.attack = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteSteal()
|
||||
{
|
||||
if (enemyVault == null)
|
||||
{
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
MoveToward(enemyVault.transform.position);
|
||||
}
|
||||
|
||||
private void ExecuteDefend()
|
||||
{
|
||||
if (myVault == null)
|
||||
{
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the thief attacking our vault
|
||||
GameObject nearestEnemy = FindNearestEnemy();
|
||||
if (nearestEnemy != null)
|
||||
{
|
||||
currentTarget = nearestEnemy.transform;
|
||||
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.SetTarget(nearestEnemy);
|
||||
aiController.NavigateTo(nearestEnemy.transform.position);
|
||||
}
|
||||
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.attack = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
MoveToward(myVault.transform.position);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteRetreat()
|
||||
{
|
||||
if (myVault == null)
|
||||
{
|
||||
SetState(AIState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run back to vault
|
||||
MoveToward(myVault.transform.position);
|
||||
|
||||
// Deposit if we have cash
|
||||
if (cashCarrier != null && cashCarrier.CarriedCash > 0)
|
||||
{
|
||||
float dist = Vector3.Distance(transform.position, myVault.transform.position);
|
||||
if (dist < 3f)
|
||||
{
|
||||
cashCarrier.DepositToVault(myVault);
|
||||
}
|
||||
}
|
||||
|
||||
// Recover health (if we have regen)
|
||||
if (health != null && health.currentHealth > retreatHealthThreshold * 1.5f)
|
||||
{
|
||||
SetState(AIState.Idle);
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveToward(Vector3 position)
|
||||
{
|
||||
if (aiController != null)
|
||||
{
|
||||
aiController.NavigateTo(position);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Direct movement if no AI controller
|
||||
Vector3 dir = (position - transform.position).normalized;
|
||||
transform.position += dir * 3f * Time.deltaTime;
|
||||
transform.LookAt(position);
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject FindNearestEnemy()
|
||||
{
|
||||
// Use TeamMember static registry (zero-allocation) instead of FindGameObjectsWithTag
|
||||
GameObject nearest = null;
|
||||
float nearestDist = float.MaxValue;
|
||||
|
||||
foreach (TeamMember member in TeamMember.AllMembers)
|
||||
{
|
||||
if (member == null || member.gameObject == gameObject) continue;
|
||||
if (member.gameObject.CompareTag(myTeamTag)) continue; // Same team, skip
|
||||
|
||||
// Check if alive
|
||||
HealthNew enemyHealth = member.GetComponent<HealthNew>();
|
||||
if (enemyHealth != null && enemyHealth.currentHealth <= 0) continue;
|
||||
|
||||
float dist = Vector3.Distance(transform.position, member.transform.position);
|
||||
if (dist < nearestDist)
|
||||
{
|
||||
nearestDist = dist;
|
||||
nearest = member.gameObject;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private CashBag FindNearestCashBag()
|
||||
{
|
||||
// Use EntityRegistry (zero-alloc) instead of FindObjectsOfType
|
||||
var bags = EntityRegistry<CashBag>.GetAll();
|
||||
|
||||
CashBag nearest = null;
|
||||
float nearestDist = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < bags.Count; i++)
|
||||
{
|
||||
CashBag bag = bags[i];
|
||||
if (bag == null) continue;
|
||||
// Accept Available or Dropped bags (not Carried)
|
||||
if (!bag.IsAvailable && !bag.IsDropped) continue;
|
||||
|
||||
float dist = Vector3.Distance(transform.position, bag.transform.position);
|
||||
if (dist < nearestDist)
|
||||
{
|
||||
nearestDist = dist;
|
||||
nearest = bag;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
7
Assets/Scripts/CashSystem/CashSystemAI.cs.bak.meta
Normal file
7
Assets/Scripts/CashSystem/CashSystemAI.cs.bak.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5aec122c92ee9438f9f63d8380f95a54
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
2
Assets/Scripts/CashSystem/CashSystemAI.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashSystemAI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f09b669c3c561e97c9daa3b163b4ccc7
|
||||
465
Assets/Scripts/CashSystem/CashSystemCharacterSelection.cs
Normal file
465
Assets/Scripts/CashSystem/CashSystemCharacterSelection.cs
Normal file
@@ -0,0 +1,465 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
|
||||
/// <summary>
|
||||
/// CashSystemCharacterSelection - Handles 3v3 team selection for Cash System mode.
|
||||
/// User can select any/all team members, or let the game auto-fill.
|
||||
/// Uses existing CharacterSelection data - no duplication.
|
||||
/// </summary>
|
||||
public class CashSystemCharacterSelection : MonoBehaviour
|
||||
{
|
||||
[Header("Team Slots - Assign the button GameObjects (3.1, 3.2, 3.3)")]
|
||||
[SerializeField] private Button[] teamSlotButtons; // All 3 slot buttons
|
||||
|
||||
[Header("Slot UI Components - Arrays aligned with teamSlotButtons")]
|
||||
[SerializeField] private Image[] slotImages; // Character images for each slot
|
||||
[SerializeField] private GameObject[] slotAddIcons; // "+" icons for each slot
|
||||
[SerializeField] private TextMeshProUGUI[] slotTexts; // Text labels
|
||||
|
||||
[Header("Confirm Button")]
|
||||
[SerializeField] private Button confirmButton;
|
||||
|
||||
[Header("References - Use existing systems")]
|
||||
[SerializeField] private ModeSelection modeSelection;
|
||||
|
||||
// Available characters - matches CharacterSelection order
|
||||
private readonly string[] allCharacters = { "Windham", "Amira", "Bahman", "Ziggy", "Amon", "Imani" };
|
||||
|
||||
// Team selection state
|
||||
private string[] playerTeam = new string[3];
|
||||
private string[] enemyTeam = new string[3];
|
||||
private int currentSlotSelecting = -1; // Which slot is currently being selected
|
||||
private int captainIndex = 0; // First selected becomes captain
|
||||
private bool captainChosen = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Only activate for Cash System mode
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only reset if we haven't started selecting yet
|
||||
// Don't reset if we're returning from character selection with a character chosen
|
||||
if (!captainChosen && string.IsNullOrEmpty(playerTeam[0]))
|
||||
{
|
||||
Debug.Log("[CashSystemCharacterSelection] Initializing 3v3 selection (first time)");
|
||||
ResetSelection();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[CashSystemCharacterSelection] Returning to 3v3 selection (preserving state)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset all slots to initial state (show add icons, hide character images)
|
||||
/// </summary>
|
||||
public void ResetSelection()
|
||||
{
|
||||
captainChosen = false;
|
||||
captainIndex = 0;
|
||||
playerTeam = new string[3];
|
||||
enemyTeam = new string[3];
|
||||
currentSlotSelecting = -1;
|
||||
|
||||
// Reset all slots
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (i < slotImages.Length && slotImages[i] != null)
|
||||
slotImages[i].gameObject.SetActive(false);
|
||||
|
||||
if (i < slotAddIcons.Length && slotAddIcons[i] != null)
|
||||
slotAddIcons[i].SetActive(true);
|
||||
|
||||
if (i < slotTexts.Length && slotTexts[i] != null)
|
||||
slotTexts[i].text = i == 0 ? "CAPTAIN" : "TEAMMATE";
|
||||
}
|
||||
|
||||
// Disable confirm until at least captain is selected
|
||||
if (confirmButton != null) confirmButton.interactable = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when ANY slot button is clicked - opens character selection for that slot
|
||||
/// </summary>
|
||||
public void OnSlotClicked(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= 3) return;
|
||||
|
||||
Debug.Log($"[CashSystemCharacterSelection] Slot {slotIndex + 1} clicked - opening character selection");
|
||||
|
||||
currentSlotSelecting = slotIndex;
|
||||
|
||||
// Navigate to character selection screen using existing ModeSelection
|
||||
if (modeSelection != null && modeSelection.selectionScreen != null)
|
||||
{
|
||||
// Hide 3v3 graphic temporarily
|
||||
modeSelection.graphic3v3.SetActive(false);
|
||||
|
||||
// Hide mode selection screen (parent screen that may be covering)
|
||||
if (modeSelection.modeSelectionScreen != null)
|
||||
{
|
||||
modeSelection.modeSelectionScreen.SetActive(false);
|
||||
}
|
||||
|
||||
// Show character selection screen
|
||||
modeSelection.selectionScreen.SetActive(true);
|
||||
|
||||
// Add listeners using existing system
|
||||
if (modeSelection.CharacterSelectionScript != null)
|
||||
{
|
||||
var charSelect = modeSelection.CharacterSelectionScript;
|
||||
|
||||
if (charSelect.selectCharacterButton != null)
|
||||
{
|
||||
charSelect.selectCharacterButton.onClick.AddListener(modeSelection.onSelectionScreenSelectClick);
|
||||
}
|
||||
|
||||
if (charSelect.backButton != null)
|
||||
{
|
||||
charSelect.backButton.onClick.AddListener(OnCharacterSelectionBack);
|
||||
}
|
||||
|
||||
// Select first character button
|
||||
if (charSelect.buttons != null && charSelect.buttons.Length > 0)
|
||||
{
|
||||
charSelect.buttons[0].Select();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for button OnClick - call OnSlotClicked with correct index
|
||||
public void OnSlot1Clicked() => OnSlotClicked(0);
|
||||
public void OnSlot2Clicked() => OnSlotClicked(1);
|
||||
public void OnSlot3Clicked() => OnSlotClicked(2);
|
||||
|
||||
/// <summary>
|
||||
/// Called when a character is selected from the character selection screen
|
||||
/// </summary>
|
||||
public void OnCharacterSelected(string characterName)
|
||||
{
|
||||
Debug.Log($"[CashSystemCharacterSelection] OnCharacterSelected called with: {characterName}, currentSlotSelecting: {currentSlotSelecting}");
|
||||
|
||||
if (currentSlotSelecting < 0 || currentSlotSelecting >= 3)
|
||||
{
|
||||
Debug.LogWarning("[CashSystemCharacterSelection] No slot was being selected!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if character is already selected in another slot
|
||||
for (int i = 0; i < playerTeam.Length; i++)
|
||||
{
|
||||
if (playerTeam[i] == characterName && i != currentSlotSelecting)
|
||||
{
|
||||
Debug.LogWarning($"[CashSystemCharacterSelection] {characterName} already selected in slot {i + 1}");
|
||||
// Could show a warning UI here
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[CashSystemCharacterSelection] Slot {currentSlotSelecting + 1} selected: {characterName}");
|
||||
|
||||
// Set the character for this slot
|
||||
playerTeam[currentSlotSelecting] = characterName;
|
||||
|
||||
// First selection becomes captain - auto-fill other slots immediately
|
||||
if (!captainChosen)
|
||||
{
|
||||
captainIndex = currentSlotSelecting;
|
||||
captainChosen = true;
|
||||
Debug.Log($"[CashSystemCharacterSelection] Captain set to slot {captainIndex + 1}");
|
||||
|
||||
// Auto-fill other slots immediately after captain selection
|
||||
AutoFillEmptySlots();
|
||||
}
|
||||
|
||||
// Update slot UI - use existing CharacterSelection sprites
|
||||
Debug.Log($"[CashSystemCharacterSelection] Calling UpdateSlotUI for slot {currentSlotSelecting}");
|
||||
UpdateSlotUI(currentSlotSelecting, characterName);
|
||||
|
||||
// Enable confirm button since at least captain is selected
|
||||
if (confirmButton != null) confirmButton.interactable = true;
|
||||
|
||||
int slotThatWasSelected = currentSlotSelecting;
|
||||
currentSlotSelecting = -1;
|
||||
|
||||
Debug.Log($"[CashSystemCharacterSelection] Character selection complete for slot {slotThatWasSelected + 1}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a slot's UI with the selected character - uses existing CharacterSelection data
|
||||
/// </summary>
|
||||
private void UpdateSlotUI(int slotIndex, string characterName)
|
||||
{
|
||||
Debug.Log($"[CashSystemCharacterSelection] UpdateSlotUI - slotIndex: {slotIndex}, characterName: {characterName}");
|
||||
|
||||
if (slotIndex < 0 || slotIndex >= 3) return;
|
||||
|
||||
// Hide add icon
|
||||
if (slotIndex < slotAddIcons.Length && slotAddIcons[slotIndex] != null)
|
||||
{
|
||||
slotAddIcons[slotIndex].SetActive(false);
|
||||
Debug.Log($"[CashSystemCharacterSelection] Hidden add icon for slot {slotIndex + 1}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[CashSystemCharacterSelection] slotAddIcons not assigned for index {slotIndex}");
|
||||
}
|
||||
|
||||
// Show and set character image - get from existing CharacterSelection
|
||||
if (slotIndex < slotImages.Length && slotImages[slotIndex] != null)
|
||||
{
|
||||
slotImages[slotIndex].gameObject.SetActive(true);
|
||||
|
||||
// Get sprite from CharacterSelection
|
||||
Sprite characterSprite = GetCharacterSprite(characterName);
|
||||
if (characterSprite != null)
|
||||
{
|
||||
slotImages[slotIndex].sprite = characterSprite;
|
||||
Debug.Log($"[CashSystemCharacterSelection] Set sprite for slot {slotIndex + 1}: {characterSprite.name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[CashSystemCharacterSelection] Could not find sprite for {characterName}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[CashSystemCharacterSelection] slotImages not assigned for index {slotIndex}. Array length: {slotImages?.Length ?? 0}");
|
||||
}
|
||||
|
||||
// Update text
|
||||
if (slotIndex < slotTexts.Length && slotTexts[slotIndex] != null)
|
||||
{
|
||||
string label = characterName.ToUpper();
|
||||
if (slotIndex == captainIndex)
|
||||
{
|
||||
label += " ★"; // Mark captain
|
||||
}
|
||||
slotTexts[slotIndex].text = label;
|
||||
Debug.Log($"[CashSystemCharacterSelection] Set text for slot {slotIndex + 1}: {label}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[CashSystemCharacterSelection] slotTexts not assigned for index {slotIndex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get character sprite from existing CharacterSelection - no duplication
|
||||
/// </summary>
|
||||
private Sprite GetCharacterSprite(string characterName)
|
||||
{
|
||||
if (modeSelection == null || modeSelection.CharacterSelectionScript == null)
|
||||
return null;
|
||||
|
||||
var charSelect = modeSelection.CharacterSelectionScript;
|
||||
|
||||
// Find character index
|
||||
for (int i = 0; i < charSelect.characterNames.Length; i++)
|
||||
{
|
||||
if (charSelect.characterNames[i].Equals(characterName, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i < charSelect.characterImages.Length)
|
||||
{
|
||||
return charSelect.characterImages[i];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when back is pressed in character selection
|
||||
/// </summary>
|
||||
private void OnCharacterSelectionBack()
|
||||
{
|
||||
currentSlotSelecting = -1;
|
||||
|
||||
if (modeSelection != null)
|
||||
{
|
||||
// Hide selection screen
|
||||
modeSelection.selectionScreen.SetActive(false);
|
||||
|
||||
// Show mode selection screen again
|
||||
if (modeSelection.modeSelectionScreen != null)
|
||||
{
|
||||
modeSelection.modeSelectionScreen.SetActive(true);
|
||||
}
|
||||
|
||||
// Show 3v3 graphic again
|
||||
modeSelection.graphic3v3.SetActive(true);
|
||||
|
||||
// Remove listeners
|
||||
if (modeSelection.CharacterSelectionScript != null)
|
||||
{
|
||||
var charSelect = modeSelection.CharacterSelectionScript;
|
||||
charSelect.selectCharacterButton.onClick.RemoveListener(modeSelection.onSelectionScreenSelectClick);
|
||||
charSelect.backButton.onClick.RemoveListener(OnCharacterSelectionBack);
|
||||
}
|
||||
|
||||
// Select the slot that was being edited
|
||||
if (teamSlotButtons != null && teamSlotButtons.Length > 0)
|
||||
{
|
||||
teamSlotButtons[0].Select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Confirm button is clicked - auto-fill empty slots and start game
|
||||
/// </summary>
|
||||
public void OnConfirmClicked()
|
||||
{
|
||||
Debug.Log("[CashSystemCharacterSelection] OnConfirmClicked called!");
|
||||
|
||||
if (!captainChosen)
|
||||
{
|
||||
Debug.LogWarning("[CashSystemCharacterSelection] Cannot confirm - no captain selected!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill any empty slots with random characters (should already be filled)
|
||||
AutoFillEmptySlots();
|
||||
|
||||
// Generate enemy team
|
||||
GenerateEnemyTeam();
|
||||
|
||||
Debug.Log($"[CashSystemCharacterSelection] Final Player Team: {string.Join(", ", playerTeam)}");
|
||||
Debug.Log($"[CashSystemCharacterSelection] Final Enemy Team: {string.Join(", ", enemyTeam)}");
|
||||
Debug.Log($"[CashSystemCharacterSelection] Captain Index: {captainIndex}");
|
||||
|
||||
// Save team data to SelectionOptions
|
||||
if (SelectionOptions.Instance != null)
|
||||
{
|
||||
SelectionOptions.Instance.playerTeam = playerTeam;
|
||||
SelectionOptions.Instance.enemyTeam = enemyTeam;
|
||||
SelectionOptions.Instance.captainIndex = captainIndex;
|
||||
SelectionOptions.Instance.currentControlledIndex = captainIndex;
|
||||
|
||||
// Store portrait sprites so the Game scene can display them
|
||||
SelectionOptions.Instance.playerTeamSprites = new Sprite[3];
|
||||
SelectionOptions.Instance.enemyTeamSprites = new Sprite[3];
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
SelectionOptions.Instance.playerTeamSprites[i] = GetCharacterSprite(playerTeam[i]);
|
||||
SelectionOptions.Instance.enemyTeamSprites[i] = GetCharacterSprite(enemyTeam[i]);
|
||||
}
|
||||
|
||||
Debug.Log("[CashSystemCharacterSelection] Saved team data + sprites to SelectionOptions");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[CashSystemCharacterSelection] SelectionOptions.Instance is null!");
|
||||
}
|
||||
|
||||
// Set game mode
|
||||
if (SelectionOptions.Instance != null)
|
||||
{
|
||||
SelectionOptions.Instance.isCashSystemSelected = true;
|
||||
Debug.Log("[CashSystemCharacterSelection] Set isCashSystemSelected = true");
|
||||
}
|
||||
|
||||
if (GameManager.Instance != null)
|
||||
{
|
||||
GameManager.Instance.currentGameMode = GameMode.CashSystem;
|
||||
Debug.Log("[CashSystemCharacterSelection] Set game mode to CashSystem");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[CashSystemCharacterSelection] GameManager.Instance is null!");
|
||||
}
|
||||
|
||||
// Load game scene
|
||||
Debug.Log("[CashSystemCharacterSelection] Loading Game scene...");
|
||||
SceneManager.LoadScene("Game");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-fill any empty player team slots with random available characters
|
||||
/// </summary>
|
||||
private void AutoFillEmptySlots()
|
||||
{
|
||||
// Get list of available characters (not already selected)
|
||||
var available = new System.Collections.Generic.List<string>(allCharacters);
|
||||
foreach (string selected in playerTeam)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
available.Remove(selected);
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle available
|
||||
for (int i = available.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Range(0, i + 1);
|
||||
string temp = available[i];
|
||||
available[i] = available[j];
|
||||
available[j] = temp;
|
||||
}
|
||||
|
||||
// Fill empty slots
|
||||
int availableIndex = 0;
|
||||
for (int i = 0; i < playerTeam.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playerTeam[i]) && availableIndex < available.Count)
|
||||
{
|
||||
playerTeam[i] = available[availableIndex];
|
||||
UpdateSlotUI(i, playerTeam[i]);
|
||||
availableIndex++;
|
||||
Debug.Log($"[CashSystemCharacterSelection] Auto-filled slot {i + 1} with {playerTeam[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate enemy team from remaining characters
|
||||
/// </summary>
|
||||
private void GenerateEnemyTeam()
|
||||
{
|
||||
// Get characters not in player team
|
||||
var available = new System.Collections.Generic.List<string>(allCharacters);
|
||||
foreach (string selected in playerTeam)
|
||||
{
|
||||
available.Remove(selected);
|
||||
}
|
||||
|
||||
// Shuffle and assign
|
||||
for (int i = available.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Range(0, i + 1);
|
||||
string temp = available[i];
|
||||
available[i] = available[j];
|
||||
available[j] = temp;
|
||||
}
|
||||
|
||||
for (int i = 0; i < enemyTeam.Length && i < available.Count; i++)
|
||||
{
|
||||
enemyTeam[i] = available[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Back button in 3v3 screen is clicked
|
||||
/// </summary>
|
||||
public void OnBackClicked()
|
||||
{
|
||||
ResetSelection();
|
||||
SelectionOptions.Instance.isCashSystemSelected = false;
|
||||
|
||||
// Navigate back to mode selection
|
||||
if (modeSelection != null)
|
||||
{
|
||||
modeSelection.graphic3v3.SetActive(false);
|
||||
// Return to home or mode selection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6735d1bcb03a417a3b7740abefe5c85e
|
||||
1631
Assets/Scripts/CashSystem/CashSystemManager.cs
Normal file
1631
Assets/Scripts/CashSystem/CashSystemManager.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
Assets/Scripts/CashSystem/CashSystemManager.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashSystemManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16b2e908cc98322f99c8bbc3305e0322
|
||||
663
Assets/Scripts/CashSystem/CashSystemUI.cs
Normal file
663
Assets/Scripts/CashSystem/CashSystemUI.cs
Normal file
@@ -0,0 +1,663 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// CashSystemUI — Event-driven HUD for Cash System mode.
|
||||
/// Subscribes to GameEvents; never polls via FindObjectOfType.
|
||||
/// Includes: timer, vault bars, carried cash, kill feed, combo popup, vault attack warning, countdown.
|
||||
/// </summary>
|
||||
public class CashSystemUI : MonoBehaviour
|
||||
{
|
||||
[Header("Timer Display")]
|
||||
[SerializeField] private TextMeshProUGUI timerText;
|
||||
|
||||
[Header("Vault Displays")]
|
||||
[SerializeField] private TextMeshProUGUI playerVaultText;
|
||||
[SerializeField] private TextMeshProUGUI enemyVaultText;
|
||||
[SerializeField] private Slider playerVaultBar;
|
||||
[SerializeField] private Slider enemyVaultBar;
|
||||
|
||||
[Header("Carried Cash Display")]
|
||||
[SerializeField] private TextMeshProUGUI carriedCashText;
|
||||
[SerializeField] private GameObject carriedCashPanel;
|
||||
|
||||
[Header("Character Switch Indicator")]
|
||||
[SerializeField] private TextMeshProUGUI currentCharacterText;
|
||||
[SerializeField] private Image[] teamCharacterIcons;
|
||||
|
||||
[Header("Steal Progress")]
|
||||
[SerializeField] private GameObject stealProgressPanel;
|
||||
[SerializeField] private Slider stealProgressBar;
|
||||
[SerializeField] private TextMeshProUGUI stealProgressText;
|
||||
|
||||
[Header("Steal Dial (optional)")]
|
||||
[Tooltip("Optional sprite to use for the steal progress radial dial. If left empty the code will try to copy the import UI's dial sprite at runtime.")]
|
||||
[SerializeField] private Sprite stealDialSprite;
|
||||
[Header("Countdown Display")]
|
||||
[SerializeField] private TextMeshProUGUI countdownText;
|
||||
[SerializeField] private GameObject countdownPanel;
|
||||
|
||||
[Header("Kill Feed")]
|
||||
[SerializeField] private TextMeshProUGUI killFeedText;
|
||||
[SerializeField] private int maxFeedEntries = 5;
|
||||
|
||||
[Header("Combo Popup")]
|
||||
[SerializeField] private TextMeshProUGUI comboText;
|
||||
[SerializeField] private GameObject comboPanel;
|
||||
|
||||
[Header("Popup Message")]
|
||||
[SerializeField] private TextMeshProUGUI popupText;
|
||||
[SerializeField] private GameObject popupPanel;
|
||||
|
||||
[Header("Pickup Screen Effect")]
|
||||
[Tooltip("Full-screen flash image (stretch to fill, starts transparent)")]
|
||||
[SerializeField] private Image pickupFlashImage;
|
||||
[Tooltip("Color of the screen flash when picking up cash")]
|
||||
[SerializeField] private Color pickupFlashColor = new Color(1f, 0.84f, 0f, 0.35f); // Gold
|
||||
[Tooltip("How long the flash lasts")]
|
||||
[SerializeField] private float pickupFlashDuration = 0.3f;
|
||||
[Tooltip("Text popup showing collected amount (e.g. '+$100')")]
|
||||
[SerializeField] private TextMeshProUGUI pickupAmountText;
|
||||
[SerializeField] private GameObject pickupAmountPanel;
|
||||
|
||||
[Header("Win Target")]
|
||||
[SerializeField] private float targetCash = 2000f;
|
||||
|
||||
// ─── Runtime ─────────────────────────────────────────────
|
||||
private readonly List<string> _feedEntries = new List<string>();
|
||||
private Coroutine _comboFade;
|
||||
private Coroutine _popupFade;
|
||||
private Coroutine _pickupFlashCoroutine;
|
||||
private float _originalTimerFontSize = -1f;
|
||||
private bool _dirtyVault;
|
||||
|
||||
// Cached score values (set by events, read in LateUpdate for dirty-flag refresh)
|
||||
private float _playerScore, _enemyScore;
|
||||
|
||||
// Cached carrier reference — invalidated on character switch
|
||||
private CashCarrier _cachedCarrier;
|
||||
private GameObject _cachedCarrierCharacter;
|
||||
private bool _stealWasActive;
|
||||
|
||||
// CanvasGroup for intro delay — hidden until intro panel finishes
|
||||
private CanvasGroup _canvasGroup;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up CanvasGroup so we can hide during intro without disabling the script
|
||||
_canvasGroup = GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) _canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
// Hide the HUD during the intro panel — CashSystemManager will show it after intro completes
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
|
||||
if (playerVaultBar != null) playerVaultBar.maxValue = targetCash;
|
||||
if (enemyVaultBar != null) enemyVaultBar.maxValue = targetCash;
|
||||
if (stealProgressPanel != null) stealProgressPanel.SetActive(false);
|
||||
ConfigureStealDial();
|
||||
if (comboPanel != null) comboPanel.SetActive(false);
|
||||
if (popupPanel != null) popupPanel.SetActive(false);
|
||||
|
||||
// Explicitly hide carried cash panel and clear text on start
|
||||
HideCarried();
|
||||
if (carriedCashText != null) carriedCashText.text = "";
|
||||
}
|
||||
|
||||
/// <summary>Show the HUD (called by CashSystemManager after intro completes).</summary>
|
||||
public void ShowHUD()
|
||||
{
|
||||
if (_canvasGroup == null) return;
|
||||
_canvasGroup.alpha = 1f;
|
||||
_canvasGroup.blocksRaycasts = true;
|
||||
_canvasGroup.interactable = true;
|
||||
Debug.Log("[CashSystemUI] HUD shown");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure the steal progress dial to match the import UI's dial if possible.
|
||||
/// Tries in this order:
|
||||
/// 1) If `stealDialSprite` is assigned in inspector, use that.
|
||||
/// 2) If an ImportUIPanel exists in the scene (created by CashBoxImportController), copy its ProgressDial sprite.
|
||||
/// 3) If we have a Slider with a fill Rect, apply the sprite as a radial Image.
|
||||
/// </summary>
|
||||
private void ConfigureStealDial()
|
||||
{
|
||||
if (stealProgressBar == null) return;
|
||||
|
||||
Sprite spriteToUse = stealDialSprite;
|
||||
|
||||
// Try to find the runtime ImportUIPanel created by CashBoxImportController
|
||||
if (spriteToUse == null)
|
||||
{
|
||||
var importPanel = GameObject.Find("ImportUIPanel");
|
||||
if (importPanel != null)
|
||||
{
|
||||
var dial = importPanel.transform.Find("ProgressDial");
|
||||
if (dial != null)
|
||||
{
|
||||
var img = dial.GetComponent<Image>();
|
||||
if (img != null) spriteToUse = img.sprite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (spriteToUse == null) return;
|
||||
|
||||
// Apply sprite to the Slider's fill image if available
|
||||
var fillRect = stealProgressBar.fillRect;
|
||||
if (fillRect != null)
|
||||
{
|
||||
var fillImg = fillRect.GetComponent<Image>();
|
||||
if (fillImg != null)
|
||||
{
|
||||
fillImg.sprite = spriteToUse;
|
||||
fillImg.type = Image.Type.Filled;
|
||||
fillImg.fillMethod = Image.FillMethod.Radial360;
|
||||
fillImg.fillOrigin = (int)Image.Origin360.Top;
|
||||
fillImg.fillClockwise = true;
|
||||
fillImg.preserveAspect = true;
|
||||
}
|
||||
}
|
||||
|
||||
// COPY ADDITIONAL VISUAL SETTINGS FROM IMPORT UI
|
||||
var importPanelObj = GameObject.Find("ImportUIPanel");
|
||||
if (importPanelObj != null)
|
||||
{
|
||||
// Copy panel background color if stealProgressPanel has an Image
|
||||
var importImage = importPanelObj.GetComponent<Image>();
|
||||
if (importImage != null && stealProgressPanel != null)
|
||||
{
|
||||
var stealPanelImg = stealProgressPanel.GetComponent<Image>();
|
||||
if (stealPanelImg != null)
|
||||
{
|
||||
stealPanelImg.color = importImage.color;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy font/size/color from Import's AmountText to our stealProgressText
|
||||
var amountTf = importPanelObj.transform.Find("AmountText");
|
||||
if (amountTf != null && stealProgressText != null)
|
||||
{
|
||||
var importAmountTmp = amountTf.GetComponent<TextMeshProUGUI>();
|
||||
if (importAmountTmp != null)
|
||||
{
|
||||
// Copy common TMP settings
|
||||
stealProgressText.font = importAmountTmp.font;
|
||||
stealProgressText.fontSize = importAmountTmp.fontSize;
|
||||
stealProgressText.color = importAmountTmp.color;
|
||||
stealProgressText.fontStyle = importAmountTmp.fontStyle;
|
||||
stealProgressText.enableAutoSizing = importAmountTmp.enableAutoSizing;
|
||||
stealProgressText.outlineWidth = importAmountTmp.outlineWidth;
|
||||
stealProgressText.outlineColor = importAmountTmp.outlineColor;
|
||||
stealProgressText.alignment = importAmountTmp.alignment;
|
||||
}
|
||||
}
|
||||
|
||||
// Also copy countdown/status text style if present (optional)
|
||||
var countdownTf = importPanelObj.transform.Find("CountdownText");
|
||||
if (countdownTf != null && timerText != null)
|
||||
{
|
||||
var importCountdownTmp = countdownTf.GetComponent<TextMeshProUGUI>();
|
||||
if (importCountdownTmp != null)
|
||||
{
|
||||
timerText.font = importCountdownTmp.font;
|
||||
timerText.fontSize = importCountdownTmp.fontSize;
|
||||
timerText.color = importCountdownTmp.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnTimerUpdated += OnTimerUpdated;
|
||||
GameEvents.OnScoreChanged += OnScoreChanged;
|
||||
GameEvents.OnBagPickedUp += OnBagPickedUp;
|
||||
GameEvents.OnCashDeposited += OnCashDeposited;
|
||||
GameEvents.OnKillFeedEntry += OnKillFeedEntry;
|
||||
GameEvents.OnComboMultiplier += OnComboMultiplier;
|
||||
GameEvents.OnVaultUnderAttack += OnVaultUnderAttack;
|
||||
GameEvents.OnVaultDefended += OnVaultDefended;
|
||||
GameEvents.OnCountdownTick += OnCountdownTick;
|
||||
GameEvents.OnPopupMessage += OnPopupMessage;
|
||||
GameEvents.OnCharacterSwitched += OnCharacterSwitched;
|
||||
GameEvents.OnCarrierKilled += OnCarrierKilled;
|
||||
GameEvents.OnMatchEnd += OnMatchEnd;
|
||||
GameEvents.OnBagDropped += OnBagDropped;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnTimerUpdated -= OnTimerUpdated;
|
||||
GameEvents.OnScoreChanged -= OnScoreChanged;
|
||||
GameEvents.OnBagPickedUp -= OnBagPickedUp;
|
||||
GameEvents.OnCashDeposited -= OnCashDeposited;
|
||||
GameEvents.OnKillFeedEntry -= OnKillFeedEntry;
|
||||
GameEvents.OnComboMultiplier -= OnComboMultiplier;
|
||||
GameEvents.OnVaultUnderAttack -= OnVaultUnderAttack;
|
||||
GameEvents.OnVaultDefended -= OnVaultDefended;
|
||||
GameEvents.OnCountdownTick -= OnCountdownTick;
|
||||
GameEvents.OnPopupMessage -= OnPopupMessage;
|
||||
GameEvents.OnCharacterSwitched -= OnCharacterSwitched;
|
||||
GameEvents.OnCarrierKilled -= OnCarrierKilled;
|
||||
GameEvents.OnMatchEnd -= OnMatchEnd;
|
||||
GameEvents.OnBagDropped -= OnBagDropped;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// Dirty-flag: only refresh vault bars when data changed
|
||||
if (_dirtyVault)
|
||||
{
|
||||
_dirtyVault = false;
|
||||
RefreshVaultBars();
|
||||
}
|
||||
|
||||
// Steal progress: check every frame only while active, otherwise throttle to ~6Hz
|
||||
if (_stealWasActive || Time.frameCount % 10 == 0)
|
||||
{
|
||||
UpdateStealProgress();
|
||||
}
|
||||
|
||||
// Carried cash: event-driven via OnBagPickedUp/OnBagDropped/OnCashDeposited/OnCarrierKilled
|
||||
// Only poll at ~10Hz as a safety net for edge cases
|
||||
if (Time.frameCount % 6 == 0)
|
||||
{
|
||||
RefreshCarriedCash();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event Handlers ──────────────────────────────────────
|
||||
|
||||
private void OnTimerUpdated(float remaining)
|
||||
{
|
||||
if (timerText == null) return;
|
||||
int m = Mathf.FloorToInt(remaining / 60);
|
||||
int s = Mathf.FloorToInt(remaining % 60);
|
||||
timerText.text = $"{m:00}:{s:00}";
|
||||
timerText.color = remaining <= 30f
|
||||
? Color.Lerp(Color.red, Color.white, Mathf.PingPong(Time.time * 2, 1))
|
||||
: Color.white;
|
||||
}
|
||||
|
||||
private void OnScoreChanged(string teamTag, float total)
|
||||
{
|
||||
if (teamTag == "Player") _playerScore = total;
|
||||
else _enemyScore = total;
|
||||
_dirtyVault = true;
|
||||
}
|
||||
|
||||
private void OnBagPickedUp(CashBag bag, CashCarrier carrier)
|
||||
{
|
||||
// Update carrier display immediately
|
||||
RefreshCarriedCash();
|
||||
|
||||
// Only show screen effects for the player-controlled character
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr == null) return;
|
||||
GameObject controlled = mgr.CurrentControlledCharacter;
|
||||
if (controlled == null || carrier == null) return;
|
||||
if (carrier.gameObject != controlled) return;
|
||||
|
||||
// Screen flash effect
|
||||
ShowPickupFlash();
|
||||
|
||||
// Floating "+$X" amount text
|
||||
float amount = bag != null ? bag.CashValue : 0f;
|
||||
ShowPickupAmount(amount);
|
||||
}
|
||||
|
||||
private void OnBagDropped(CashBag bag, Vector3 pos)
|
||||
{
|
||||
RefreshCarriedCash();
|
||||
}
|
||||
|
||||
private void OnCashDeposited(string teamTag, float value, int combo)
|
||||
{
|
||||
_dirtyVault = true;
|
||||
RefreshCarriedCash();
|
||||
}
|
||||
|
||||
private void OnKillFeedEntry(string killer, string action, string victim)
|
||||
{
|
||||
string entry = $"<b>{killer}</b> {action} <b>{victim}</b>";
|
||||
_feedEntries.Add(entry);
|
||||
if (_feedEntries.Count > maxFeedEntries)
|
||||
_feedEntries.RemoveAt(0);
|
||||
|
||||
if (killFeedText != null)
|
||||
killFeedText.text = string.Join("\n", _feedEntries);
|
||||
}
|
||||
|
||||
private void OnComboMultiplier(int multiplier)
|
||||
{
|
||||
if (comboText == null) return;
|
||||
comboText.text = $"x{multiplier} COMBO!";
|
||||
if (comboPanel != null) comboPanel.SetActive(true);
|
||||
if (_comboFade != null) StopCoroutine(_comboFade);
|
||||
_comboFade = StartCoroutine(FadePanel(comboPanel, 2f));
|
||||
}
|
||||
|
||||
private void OnVaultUnderAttack(TeamVault vault)
|
||||
{
|
||||
if (vault.TeamTag != "Player") return;
|
||||
if (stealProgressPanel != null) stealProgressPanel.SetActive(true);
|
||||
if (stealProgressText != null) stealProgressText.text = "VAULT UNDER ATTACK!";
|
||||
}
|
||||
|
||||
private void OnVaultDefended(TeamVault vault)
|
||||
{
|
||||
if (vault.TeamTag != "Player") return;
|
||||
if (stealProgressPanel != null) stealProgressPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnCountdownTick(string text)
|
||||
{
|
||||
ShowCountdown(text);
|
||||
}
|
||||
|
||||
private void OnPopupMessage(string message, float duration)
|
||||
{
|
||||
if (popupText != null) popupText.text = message;
|
||||
if (popupPanel != null) popupPanel.SetActive(true);
|
||||
if (_popupFade != null) StopCoroutine(_popupFade);
|
||||
_popupFade = StartCoroutine(FadePanel(popupPanel, duration));
|
||||
}
|
||||
|
||||
private void OnCharacterSwitched(GameObject oldChar, GameObject newChar)
|
||||
{
|
||||
if (newChar == null) return;
|
||||
if (currentCharacterText != null)
|
||||
currentCharacterText.text = newChar.name;
|
||||
|
||||
// Highlight icon
|
||||
if (teamCharacterIcons != null)
|
||||
{
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr != null)
|
||||
{
|
||||
var team = mgr.GetTeamCharacters("Player");
|
||||
int idx = team.IndexOf(newChar);
|
||||
for (int i = 0; i < teamCharacterIcons.Length; i++)
|
||||
if (teamCharacterIcons[i] != null)
|
||||
teamCharacterIcons[i].color = i == idx ? Color.white : new Color(1, 1, 1, 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCarrierKilled(CashCarrier carrier)
|
||||
{
|
||||
RefreshCarriedCash();
|
||||
}
|
||||
|
||||
private void OnMatchEnd(string winnerTag)
|
||||
{
|
||||
// Could show final score overlay
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────
|
||||
|
||||
private void RefreshVaultBars()
|
||||
{
|
||||
if (playerVaultText != null) playerVaultText.text = $"${_playerScore:F0}";
|
||||
if (enemyVaultText != null) enemyVaultText.text = $"${_enemyScore:F0}";
|
||||
if (playerVaultBar != null) playerVaultBar.value = _playerScore;
|
||||
if (enemyVaultBar != null) enemyVaultBar.value = _enemyScore;
|
||||
}
|
||||
|
||||
private void RefreshCarriedCash()
|
||||
{
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr == null) { HideCarried(); return; }
|
||||
GameObject cur = mgr.CurrentControlledCharacter;
|
||||
if (cur == null) { HideCarried(); return; }
|
||||
|
||||
// Cache CashCarrier — only re-fetch when controlled character changes
|
||||
if (_cachedCarrierCharacter != cur)
|
||||
{
|
||||
_cachedCarrierCharacter = cur;
|
||||
_cachedCarrier = cur.GetComponent<CashCarrier>();
|
||||
}
|
||||
|
||||
if (_cachedCarrier == null || _cachedCarrier.CarriedCash < 0.01f) { HideCarried(); return; }
|
||||
|
||||
if (carriedCashText != null) carriedCashText.text = $"Carrying: ${_cachedCarrier.CarriedCash:F0}";
|
||||
if (carriedCashPanel != null) carriedCashPanel.SetActive(true);
|
||||
}
|
||||
|
||||
private void HideCarried()
|
||||
{
|
||||
if (carriedCashPanel != null) carriedCashPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void UpdateStealProgress()
|
||||
{
|
||||
// Check player vault steal progress (uses EntityRegistry now)
|
||||
var vaults = EntityRegistry<TeamVault>.GetAll();
|
||||
TeamVault playerVault = null;
|
||||
for (int i = 0; i < vaults.Count; i++)
|
||||
if (vaults[i] != null && vaults[i].TeamTag == "Player") { playerVault = vaults[i]; break; }
|
||||
|
||||
if (playerVault != null && playerVault.IsBeingStolen)
|
||||
{
|
||||
_stealWasActive = true;
|
||||
if (stealProgressPanel != null) stealProgressPanel.SetActive(true);
|
||||
if (stealProgressBar != null) stealProgressBar.value = playerVault.StealProgress;
|
||||
}
|
||||
else
|
||||
{
|
||||
_stealWasActive = false;
|
||||
if (stealProgressPanel != null) stealProgressPanel.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FadePanel(GameObject panel, float duration)
|
||||
{
|
||||
yield return new WaitForSeconds(duration);
|
||||
if (panel != null) panel.SetActive(false);
|
||||
}
|
||||
|
||||
// ─── Pickup Screen Effects ───────────────────────────────
|
||||
|
||||
private void ShowPickupFlash()
|
||||
{
|
||||
if (_pickupFlashCoroutine != null) StopCoroutine(_pickupFlashCoroutine);
|
||||
|
||||
// If no flash image assigned, create one at runtime
|
||||
if (pickupFlashImage == null)
|
||||
{
|
||||
CreatePickupFlashImage();
|
||||
}
|
||||
|
||||
if (pickupFlashImage != null)
|
||||
{
|
||||
_pickupFlashCoroutine = StartCoroutine(PickupFlashRoutine());
|
||||
}
|
||||
}
|
||||
|
||||
private void CreatePickupFlashImage()
|
||||
{
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
if (canvas == null) canvas = FindObjectOfType<Canvas>();
|
||||
if (canvas == null) return;
|
||||
|
||||
GameObject flashObj = new GameObject("PickupFlash");
|
||||
flashObj.transform.SetParent(canvas.transform, false);
|
||||
|
||||
RectTransform rt = flashObj.AddComponent<RectTransform>();
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
|
||||
pickupFlashImage = flashObj.AddComponent<Image>();
|
||||
pickupFlashImage.color = Color.clear;
|
||||
pickupFlashImage.raycastTarget = false;
|
||||
|
||||
// Make sure it renders on top
|
||||
flashObj.transform.SetAsLastSibling();
|
||||
}
|
||||
|
||||
private IEnumerator PickupFlashRoutine()
|
||||
{
|
||||
pickupFlashImage.color = pickupFlashColor;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < pickupFlashDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / pickupFlashDuration;
|
||||
pickupFlashImage.color = Color.Lerp(pickupFlashColor, Color.clear, t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
pickupFlashImage.color = Color.clear;
|
||||
}
|
||||
|
||||
private void ShowPickupAmount(float amount)
|
||||
{
|
||||
if (amount <= 0) return;
|
||||
|
||||
// If no pickup amount panel, create one dynamically
|
||||
if (pickupAmountText == null)
|
||||
{
|
||||
CreatePickupAmountText();
|
||||
}
|
||||
|
||||
if (pickupAmountText != null)
|
||||
{
|
||||
pickupAmountText.text = $"+${amount:F0}";
|
||||
if (pickupAmountPanel != null) pickupAmountPanel.SetActive(true);
|
||||
|
||||
// Auto-hide after a short duration
|
||||
StartCoroutine(FadePickupAmount());
|
||||
}
|
||||
}
|
||||
|
||||
private void CreatePickupAmountText()
|
||||
{
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
if (canvas == null) canvas = FindObjectOfType<Canvas>();
|
||||
if (canvas == null) return;
|
||||
|
||||
pickupAmountPanel = new GameObject("PickupAmountPanel");
|
||||
pickupAmountPanel.transform.SetParent(canvas.transform, false);
|
||||
|
||||
RectTransform panelRect = pickupAmountPanel.AddComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(0.5f, 0.6f);
|
||||
panelRect.anchorMax = new Vector2(0.5f, 0.6f);
|
||||
panelRect.sizeDelta = new Vector2(700f, 160f);
|
||||
panelRect.anchoredPosition = Vector2.zero;
|
||||
|
||||
GameObject textObj = new GameObject("PickupAmountText");
|
||||
textObj.transform.SetParent(pickupAmountPanel.transform, false);
|
||||
|
||||
RectTransform textRect = textObj.AddComponent<RectTransform>();
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = Vector2.zero;
|
||||
textRect.offsetMax = Vector2.zero;
|
||||
|
||||
pickupAmountText = textObj.AddComponent<TextMeshProUGUI>();
|
||||
pickupAmountText.fontSize = 96;
|
||||
pickupAmountText.alignment = TextAlignmentOptions.Center;
|
||||
pickupAmountText.color = new Color(1f, 0.92f, 0.23f); // Bright gold
|
||||
pickupAmountText.fontStyle = FontStyles.Bold;
|
||||
pickupAmountText.enableAutoSizing = false;
|
||||
|
||||
// Add outline for readability
|
||||
pickupAmountText.outlineWidth = 0.35f;
|
||||
pickupAmountText.outlineColor = new Color32(0, 0, 0, 200);
|
||||
|
||||
pickupAmountPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private IEnumerator FadePickupAmount()
|
||||
{
|
||||
float displayDuration = 1.2f;
|
||||
float fadeDuration = 0.4f;
|
||||
|
||||
if (pickupAmountText == null) yield break;
|
||||
|
||||
// Float upward animation
|
||||
RectTransform rt = pickupAmountPanel.GetComponent<RectTransform>();
|
||||
Vector2 startPos = new Vector2(0f, 0f);
|
||||
Vector2 endPos = new Vector2(0f, 50f);
|
||||
|
||||
Color startColor = pickupAmountText.color;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < displayDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / displayDuration;
|
||||
|
||||
// Float upward
|
||||
if (rt != null)
|
||||
rt.anchoredPosition = Vector2.Lerp(startPos, endPos, t);
|
||||
|
||||
// Fade out in last portion
|
||||
if (t > 0.6f)
|
||||
{
|
||||
float fadeT = (t - 0.6f) / 0.4f;
|
||||
pickupAmountText.color = new Color(startColor.r, startColor.g, startColor.b, 1f - fadeT);
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Reset
|
||||
pickupAmountText.color = startColor;
|
||||
if (rt != null) rt.anchoredPosition = startPos;
|
||||
if (pickupAmountPanel != null) pickupAmountPanel.SetActive(false);
|
||||
}
|
||||
|
||||
// ─── Legacy API (called from other scripts) ──────────────
|
||||
|
||||
public void UpdateTimerDisplay(float timeRemaining) => OnTimerUpdated(timeRemaining);
|
||||
public void UpdateVaultDisplay(TeamVault vault)
|
||||
{
|
||||
if (vault == null) return;
|
||||
if (vault.TeamTag == "Player") _playerScore = vault.StoredCash;
|
||||
else _enemyScore = vault.StoredCash;
|
||||
_dirtyVault = true;
|
||||
}
|
||||
public void UpdateCarriedCashDisplay(CashCarrier carrier) => RefreshCarriedCash();
|
||||
public void UpdateCurrentCharacter(string characterName, int index)
|
||||
{
|
||||
if (currentCharacterText != null) currentCharacterText.text = characterName;
|
||||
}
|
||||
|
||||
public void ShowCountdown(string text)
|
||||
{
|
||||
if (countdownText != null)
|
||||
countdownText.text = text;
|
||||
if (countdownPanel != null)
|
||||
countdownPanel.SetActive(!string.IsNullOrEmpty(text));
|
||||
|
||||
// Fallback: use timer text for countdown
|
||||
if (countdownText == null && timerText != null && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
if (_originalTimerFontSize < 0) _originalTimerFontSize = timerText.fontSize;
|
||||
timerText.text = text;
|
||||
timerText.fontSize = 72;
|
||||
}
|
||||
else if (countdownText == null && timerText != null && string.IsNullOrEmpty(text))
|
||||
{
|
||||
if (_originalTimerFontSize > 0) timerText.fontSize = _originalTimerFontSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/CashSystemUI.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashSystemUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70704835259870dbfa30e3d56bde388f
|
||||
520
Assets/Scripts/CashSystem/CashoutStation.cs
Normal file
520
Assets/Scripts/CashSystem/CashoutStation.cs
Normal file
@@ -0,0 +1,520 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/CashoutStation.cs.meta
Normal file
2
Assets/Scripts/CashSystem/CashoutStation.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7f2c6d188d56a044864ff5245a921c4
|
||||
571
Assets/Scripts/CashSystem/ExtractionVault.cs
Normal file
571
Assets/Scripts/CashSystem/ExtractionVault.cs
Normal file
@@ -0,0 +1,571 @@
|
||||
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
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/ExtractionVault.cs.meta
Normal file
2
Assets/Scripts/CashSystem/ExtractionVault.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e73597034e201424e81a35d9a484b454
|
||||
356
Assets/Scripts/CashSystem/TeamVault.cs
Normal file
356
Assets/Scripts/CashSystem/TeamVault.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// TeamVault — Deposit zone for team cash with steal mechanic.
|
||||
/// Uses GameEvents for all notifications (no FindObjectOfType in hot paths).
|
||||
/// EntityRegistry for fast lookups. Magnet zone for auto-deposit at close range.
|
||||
/// </summary>
|
||||
public class TeamVault : MonoBehaviour
|
||||
{
|
||||
[Header("Team Settings")]
|
||||
[SerializeField] private string teamTag = "Player"; // "Player" or "Enemy"
|
||||
[SerializeField] private float storedCash = 0f;
|
||||
|
||||
[Header("Interaction Settings")]
|
||||
[SerializeField] private float depositTime = 1.5f;
|
||||
[SerializeField] private float stealTime = 3f;
|
||||
[SerializeField] private float stealAmount = 100f;
|
||||
[SerializeField] private float magnetRange = 2.5f; // Auto-deposit range
|
||||
[SerializeField] private float magnetInterval = 0.3f; // How often to check magnet
|
||||
|
||||
[Header("Visual/Audio")]
|
||||
[SerializeField] private GameObject depositEffect;
|
||||
[SerializeField] private GameObject stealEffect;
|
||||
[SerializeField] private ParticleSystem activeParticles;
|
||||
|
||||
[Header("Import / Spawn")]
|
||||
[SerializeField] private GameObject importCashBagPrefab;
|
||||
[SerializeField] private bool spawnImportOnStart = false;
|
||||
[SerializeField] private int importCount = 1;
|
||||
|
||||
[Header("CashBox Import Animation")]
|
||||
[Tooltip("Enable animated import with countdown, camera focus, and sounds")]
|
||||
[SerializeField] private bool useAnimatedImport = true;
|
||||
[Tooltip("CashBox prefab to spawn at vault (has Animator with 'close' animation)")]
|
||||
[SerializeField] private GameObject cashBoxPrefab;
|
||||
[SerializeField] private Vector3 cashBoxOffset = Vector3.zero;
|
||||
[SerializeField] private Vector3 cashBoxRotation = new Vector3(-90f, 0f, 0f);
|
||||
[SerializeField] private Vector3 cashBoxScale = new Vector3(60f, 60f, 60f);
|
||||
|
||||
[Header("Events (Inspector)")]
|
||||
public UnityEvent<float> OnCashDepositedLocal;
|
||||
public UnityEvent<float> OnCashStolenLocal;
|
||||
public UnityEvent OnVaultUnderAttackLocal;
|
||||
|
||||
// ─── Runtime ─────────────────────────────────────────────
|
||||
private bool _isBeingStolen;
|
||||
private CashCarrier _currentThief;
|
||||
private float _stealProgress;
|
||||
private Coroutine _stealCoroutine;
|
||||
private Coroutine _magnetCoroutine;
|
||||
private readonly HashSet<CashCarrier> _depositingCarriers = new HashSet<CashCarrier>();
|
||||
private CashBoxImportController _importController;
|
||||
|
||||
public float StoredCash => storedCash;
|
||||
public string TeamTag => teamTag;
|
||||
public bool IsBeingStolen => _isBeingStolen;
|
||||
public float StealProgress => _stealProgress;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_magnetCoroutine = StartCoroutine(MagnetPulseRoutine());
|
||||
|
||||
// Optional: spawn imported cash bags at vault position
|
||||
if (spawnImportOnStart && importCashBagPrefab != null)
|
||||
{
|
||||
for (int i = 0; i < Mathf.Max(1, importCount); i++)
|
||||
{
|
||||
SpawnImportedBag(importCashBagPrefab);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup CashBox import controller if animated import is enabled
|
||||
if (useAnimatedImport && cashBoxPrefab != null)
|
||||
{
|
||||
SetupImportController();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupImportController()
|
||||
{
|
||||
_importController = GetComponent<CashBoxImportController>();
|
||||
if (_importController == null)
|
||||
_importController = gameObject.AddComponent<CashBoxImportController>();
|
||||
|
||||
// Pass the TeamVault's prefab and transform settings to the controller
|
||||
_importController.Configure(cashBoxPrefab, cashBoxOffset, cashBoxRotation, cashBoxScale);
|
||||
|
||||
// Spawn the CashBox above the vault
|
||||
_importController.SpawnCashBox();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EntityRegistry<TeamVault>.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EntityRegistry<TeamVault>.Unregister(this);
|
||||
if (_magnetCoroutine != null) StopCoroutine(_magnetCoroutine);
|
||||
}
|
||||
|
||||
// ─── Trigger ─────────────────────────────────────────────
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
CashCarrier carrier = other.GetComponentInParent<CashCarrier>();
|
||||
if (carrier == null) return;
|
||||
|
||||
// Same team → deposit
|
||||
if (carrier.TeamTag == teamTag)
|
||||
{
|
||||
if (carrier.CarriedCash > 0 && !_depositingCarriers.Contains(carrier))
|
||||
StartCoroutine(DepositRoutine(carrier));
|
||||
}
|
||||
// Enemy team → steal
|
||||
else
|
||||
{
|
||||
if (storedCash > 0 && !_isBeingStolen)
|
||||
StartStealing(carrier);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
CashCarrier carrier = other.GetComponentInParent<CashCarrier>();
|
||||
if (carrier == null) return;
|
||||
|
||||
if (carrier == _currentThief)
|
||||
CancelSteal();
|
||||
}
|
||||
|
||||
// ─── Magnet Auto-Deposit ─────────────────────────────────
|
||||
|
||||
private IEnumerator MagnetPulseRoutine()
|
||||
{
|
||||
var wait = new WaitForSeconds(magnetInterval);
|
||||
while (true)
|
||||
{
|
||||
yield return wait;
|
||||
var carriers = EntityRegistry<CashCarrier>.GetAll();
|
||||
for (int i = 0; i < carriers.Count; i++)
|
||||
{
|
||||
var c = carriers[i];
|
||||
if (c == null || c.CarriedCash <= 0 || c.TeamTag != teamTag) continue;
|
||||
if (_depositingCarriers.Contains(c)) continue;
|
||||
if (Vector3.Distance(c.transform.position, transform.position) <= magnetRange)
|
||||
StartCoroutine(DepositRoutine(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deposit ─────────────────────────────────────────────
|
||||
|
||||
private IEnumerator DepositRoutine(CashCarrier carrier)
|
||||
{
|
||||
_depositingCarriers.Add(carrier);
|
||||
|
||||
// Check if this is a player and we should use animated import
|
||||
bool isPlayer = carrier.CompareTag("Player") || carrier.gameObject.layer == LayerMask.NameToLayer("Player");
|
||||
bool useImport = useAnimatedImport && _importController != null && isPlayer;
|
||||
|
||||
if (useImport && _importController.CanImport(carrier))
|
||||
{
|
||||
// Use animated import with countdown, camera focus, sounds
|
||||
_importController.StartImport(carrier);
|
||||
|
||||
// Wait for import to complete
|
||||
while (_importController.IsImporting)
|
||||
yield return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard deposit flow (for AI or when import controller unavailable)
|
||||
yield return new WaitForSeconds(depositTime);
|
||||
|
||||
if (carrier != null && carrier.CarriedCash > 0)
|
||||
{
|
||||
// Let carrier handle combo scoring via DepositToVault
|
||||
carrier.DepositToVault(this);
|
||||
|
||||
if (depositEffect != null)
|
||||
Instantiate(depositEffect, transform.position + Vector3.up, Quaternion.identity);
|
||||
}
|
||||
}
|
||||
|
||||
_depositingCarriers.Remove(carrier);
|
||||
}
|
||||
|
||||
/// <summary>Direct deposit (called by CashCarrier.DepositToVault or legacy code).</summary>
|
||||
public void DepositCash(float amount)
|
||||
{
|
||||
storedCash += amount;
|
||||
|
||||
OnCashDepositedLocal?.Invoke(amount);
|
||||
|
||||
// Fire global events instead of FindObjectOfType
|
||||
GameEvents.FireScoreChanged(teamTag, storedCash);
|
||||
GameEvents.FireKillFeedEntry(teamTag, "deposited", $"${amount:F0}");
|
||||
}
|
||||
|
||||
// ─── Steal ───────────────────────────────────────────────
|
||||
|
||||
private void StartStealing(CashCarrier thief)
|
||||
{
|
||||
if (_isBeingStolen) return;
|
||||
|
||||
_isBeingStolen = true;
|
||||
_currentThief = thief;
|
||||
_stealProgress = 0f;
|
||||
|
||||
OnVaultUnderAttackLocal?.Invoke();
|
||||
GameEvents.FireVaultUnderAttack(this);
|
||||
|
||||
_stealCoroutine = StartCoroutine(StealRoutine(thief));
|
||||
}
|
||||
|
||||
private IEnumerator StealRoutine(CashCarrier thief)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
|
||||
while (elapsed < stealTime)
|
||||
{
|
||||
if (thief == null || !thief.enabled)
|
||||
{
|
||||
CancelSteal();
|
||||
yield break;
|
||||
}
|
||||
|
||||
HealthNew health = thief.GetComponent<HealthNew>();
|
||||
if (health != null && health.isDead)
|
||||
{
|
||||
CancelSteal();
|
||||
yield break;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
_stealProgress = elapsed / stealTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
CompleteSteal(thief);
|
||||
}
|
||||
|
||||
private void CompleteSteal(CashCarrier thief)
|
||||
{
|
||||
if (thief == null) return;
|
||||
|
||||
float stolen = Mathf.Min(stealAmount, storedCash);
|
||||
storedCash -= stolen;
|
||||
thief.AddCash(stolen);
|
||||
|
||||
if (stealEffect != null)
|
||||
Instantiate(stealEffect, transform.position + Vector3.up, Quaternion.identity);
|
||||
|
||||
OnCashStolenLocal?.Invoke(stolen);
|
||||
|
||||
_isBeingStolen = false;
|
||||
_currentThief = null;
|
||||
_stealProgress = 0f;
|
||||
|
||||
// Fire global events — replaces FindObjectOfType<CashSystemManager/CashSystemUI>
|
||||
GameEvents.FireVaultStolen(this, stolen);
|
||||
GameEvents.FireScoreChanged(teamTag, storedCash);
|
||||
GameEvents.FireKillFeedEntry(thief.gameObject.name, "stole", $"${stolen:F0}");
|
||||
}
|
||||
|
||||
private void CancelSteal()
|
||||
{
|
||||
if (_stealCoroutine != null)
|
||||
StopCoroutine(_stealCoroutine);
|
||||
|
||||
_isBeingStolen = false;
|
||||
_currentThief = null;
|
||||
_stealProgress = 0f;
|
||||
|
||||
GameEvents.FireVaultDefended(this);
|
||||
}
|
||||
|
||||
/// <summary>Force interrupt steal (e.g., when thief takes damage).</summary>
|
||||
public void InterruptSteal()
|
||||
{
|
||||
CancelSteal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn an imported cash bag at this vault's position. Uses PoolManager if available.
|
||||
/// Returns the spawned CashBag GameObject or null.
|
||||
/// </summary>
|
||||
public GameObject SpawnImportedBag(GameObject prefab)
|
||||
{
|
||||
if (prefab == null) return null;
|
||||
|
||||
GameObject obj = null;
|
||||
// Use PoolManager if present
|
||||
var poolManagerType = typeof(PoolManager);
|
||||
try
|
||||
{
|
||||
obj = PoolManager.Instance != null ? PoolManager.Instance.Spawn(prefab, transform.position + Vector3.up * 0.5f, Quaternion.identity) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
obj = null;
|
||||
}
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
obj = Instantiate(prefab, transform.position + Vector3.up * 0.5f, Quaternion.identity);
|
||||
}
|
||||
|
||||
var bag = obj.GetComponent<CashBag>();
|
||||
if (bag != null)
|
||||
{
|
||||
bag.SetCashValue(bag.CashValue);
|
||||
bag.Drop(transform.position); // Ensure bag is in dropped/available state
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>Get the opposing vault via EntityRegistry (no FindObjectsOfType).</summary>
|
||||
public TeamVault GetOpposingVault()
|
||||
{
|
||||
var vaults = EntityRegistry<TeamVault>.GetAll();
|
||||
for (int i = 0; i < vaults.Count; i++)
|
||||
{
|
||||
if (vaults[i] != null && vaults[i].teamTag != this.teamTag)
|
||||
return vaults[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = teamTag == "Player" ? new Color(0, 0, 1, 0.3f) : new Color(1, 0, 0, 0.3f);
|
||||
Gizmos.DrawSphere(transform.position, 2f);
|
||||
|
||||
Gizmos.color = new Color(1, 1, 0, 0.15f);
|
||||
Gizmos.DrawSphere(transform.position, magnetRange);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f, $"{teamTag} Vault\n${storedCash}");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/TeamVault.cs.meta
Normal file
2
Assets/Scripts/CashSystem/TeamVault.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e917c4b8cb4308a08bf6c07a278c5004
|
||||
8
Assets/Scripts/CashSystem/UI.meta
Normal file
8
Assets/Scripts/CashSystem/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 813c3ca1efd3145d9a0a04773b778ea9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
656
Assets/Scripts/CashSystem/UI/MatchResultUI.cs
Normal file
656
Assets/Scripts/CashSystem/UI/MatchResultUI.cs
Normal file
@@ -0,0 +1,656 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// MatchResultUI — Unified VICTORY / DEFEAT end-of-match panel.
|
||||
/// Works for both Cash System (team 3v3) and Practice (1v1) modes.
|
||||
///
|
||||
/// Stats auto-populate based on mode:
|
||||
/// Cash System → Kills, Cash Deposited, Cash Stolen, Damage
|
||||
/// Practice → Kills, Deaths, Assists, Damage
|
||||
///
|
||||
/// Hierarchy (from scene):
|
||||
/// Stats (root, this script)
|
||||
/// ├─ continue (Button)
|
||||
/// ├─ restart (Button)
|
||||
/// ├─ Title — result header "VICTORY" / "DEFEAT"
|
||||
/// ├─ Title(1) — score text "24 VICTORY 20"
|
||||
/// ├─ Playerstatsui
|
||||
/// │ ├─ PlayerName
|
||||
/// │ ├─ kills
|
||||
/// │ ├─ deaths
|
||||
/// │ ├─ assists
|
||||
/// │ └─ damage
|
||||
/// ├─ Enemystatsui
|
||||
/// │ ├─ EnemyName
|
||||
/// │ ├─ kills
|
||||
/// │ ├─ deaths
|
||||
/// │ ├─ assists
|
||||
/// │ └─ damage
|
||||
/// └─ Victory tab
|
||||
/// </summary>
|
||||
public class MatchResultUI : MonoBehaviour
|
||||
{
|
||||
[Header("Panel Root")]
|
||||
[Tooltip("The root panel that gets shown/hidden. If null, uses this.gameObject.")]
|
||||
[SerializeField] private GameObject rootPanel;
|
||||
|
||||
[Header("Header")]
|
||||
[SerializeField] private TextMeshProUGUI resultHeaderText; // "VICTORY" / "DEFEAT"
|
||||
[SerializeField] private TextMeshProUGUI scoreText; // "24 VICTORY 20" style
|
||||
|
||||
[Header("Team Names")]
|
||||
[SerializeField] private TextMeshProUGUI playerTeamLabel; // e.g. "CHEETAH WIN!"
|
||||
[SerializeField] private TextMeshProUGUI enemyTeamLabel; // e.g. "RABBIT LOSE!"
|
||||
|
||||
[Header("Player Stats Texts")]
|
||||
[Tooltip("Player/team name text inside Playerstatsui")]
|
||||
[SerializeField] private TextMeshProUGUI playerNameText;
|
||||
[Tooltip("Stat row 1 — Kills (both modes)")]
|
||||
[SerializeField] private TextMeshProUGUI playerKillsText;
|
||||
[Tooltip("Stat row 2 — Deaths (practice) / Cash Deposited (cash system)")]
|
||||
[SerializeField] private TextMeshProUGUI playerDeathsText;
|
||||
[Tooltip("Stat row 3 — Assists (practice) / Cash Stolen (cash system)")]
|
||||
[SerializeField] private TextMeshProUGUI playerAssistsText;
|
||||
[Tooltip("Stat row 4 — Damage (both modes)")]
|
||||
[SerializeField] private TextMeshProUGUI playerDamageText;
|
||||
|
||||
[Header("Enemy Stats Texts")]
|
||||
[Tooltip("Enemy/team name text inside Enemystatsui")]
|
||||
[SerializeField] private TextMeshProUGUI enemyNameText;
|
||||
[SerializeField] private TextMeshProUGUI enemyKillsText;
|
||||
[SerializeField] private TextMeshProUGUI enemyDeathsText;
|
||||
[SerializeField] private TextMeshProUGUI enemyAssistsText;
|
||||
[SerializeField] private TextMeshProUGUI enemyDamageText;
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button continueButton;
|
||||
[SerializeField] private Button restartButton;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color victoryColor = new Color(1f, 0.84f, 0f); // Gold
|
||||
[SerializeField] private Color defeatColor = new Color(0.96f, 0.26f, 0.21f); // Red
|
||||
[SerializeField] private Color panelBgColor = new Color(0.08f, 0.12f, 0.18f, 0.92f);
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private AudioClip victorySFX;
|
||||
[SerializeField] private AudioClip defeatSFX;
|
||||
[SerializeField] private AudioClip buttonClickSFX;
|
||||
[SerializeField] private float sfxVolume = 1f;
|
||||
|
||||
[Header("Animation")]
|
||||
[SerializeField] private float revealDuration = 0.6f;
|
||||
|
||||
// runtime
|
||||
private AudioSource _audio;
|
||||
private CanvasGroup _canvasGroup;
|
||||
private float _savedFixedDeltaTime;
|
||||
|
||||
private static MatchResultUI _instance;
|
||||
public static MatchResultUI Instance => _instance;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a MatchResultUI instance exists in the scene.
|
||||
/// Finds inactive instances first, or creates a minimal one with buttons on the existing Canvas.
|
||||
/// Call from CashSystemManager.Start() so end-of-match always shows a panel.
|
||||
/// </summary>
|
||||
public static void EnsureExists()
|
||||
{
|
||||
if (_instance != null) return;
|
||||
|
||||
// 1. Try to find an inactive instance already in the scene
|
||||
MatchResultUI[] all = Resources.FindObjectsOfTypeAll<MatchResultUI>();
|
||||
foreach (var m in all)
|
||||
{
|
||||
if (m.gameObject.scene.name == null) continue;
|
||||
// Activate parents top-down first, then the object itself
|
||||
List<GameObject> chain = new List<GameObject>();
|
||||
Transform t = m.transform;
|
||||
while (t != null)
|
||||
{
|
||||
if (!t.gameObject.activeSelf)
|
||||
chain.Add(t.gameObject);
|
||||
t = t.parent;
|
||||
}
|
||||
for (int i = chain.Count - 1; i >= 0; i--)
|
||||
chain[i].SetActive(true);
|
||||
|
||||
if (_instance != null)
|
||||
{
|
||||
DevLog.Log("[MatchResultUI] Found existing inactive panel, activated it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create a minimal panel on an existing Canvas
|
||||
Canvas canvas = Object.FindObjectOfType<Canvas>();
|
||||
if (canvas == null)
|
||||
{
|
||||
GameObject canvasObj = new GameObject("UICanvas");
|
||||
canvas = canvasObj.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 100;
|
||||
canvasObj.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
canvasObj.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
}
|
||||
|
||||
GameObject panelRoot = new GameObject("MatchResultUI_Auto");
|
||||
panelRoot.transform.SetParent(canvas.transform, false);
|
||||
|
||||
RectTransform rt = panelRoot.AddComponent<RectTransform>();
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
|
||||
// Background
|
||||
Image bg = panelRoot.AddComponent<Image>();
|
||||
bg.color = new Color(0.08f, 0.12f, 0.18f, 0.92f);
|
||||
|
||||
// Result header
|
||||
GameObject headerObj = new GameObject("ResultHeader");
|
||||
headerObj.transform.SetParent(panelRoot.transform, false);
|
||||
RectTransform hrt = headerObj.AddComponent<RectTransform>();
|
||||
hrt.anchorMin = new Vector2(0.2f, 0.7f);
|
||||
hrt.anchorMax = new Vector2(0.8f, 0.9f);
|
||||
hrt.offsetMin = Vector2.zero;
|
||||
hrt.offsetMax = Vector2.zero;
|
||||
TextMeshProUGUI headerTxt = headerObj.AddComponent<TextMeshProUGUI>();
|
||||
headerTxt.text = "MATCH RESULT";
|
||||
headerTxt.fontSize = 80;
|
||||
headerTxt.alignment = TextAlignmentOptions.Center;
|
||||
headerTxt.color = Color.white;
|
||||
|
||||
// Score text
|
||||
GameObject scoreObj = new GameObject("ScoreText");
|
||||
scoreObj.transform.SetParent(panelRoot.transform, false);
|
||||
RectTransform srt = scoreObj.AddComponent<RectTransform>();
|
||||
srt.anchorMin = new Vector2(0.2f, 0.55f);
|
||||
srt.anchorMax = new Vector2(0.8f, 0.7f);
|
||||
srt.offsetMin = Vector2.zero;
|
||||
srt.offsetMax = Vector2.zero;
|
||||
TextMeshProUGUI scoreTxt = scoreObj.AddComponent<TextMeshProUGUI>();
|
||||
scoreTxt.text = "";
|
||||
scoreTxt.fontSize = 48;
|
||||
scoreTxt.alignment = TextAlignmentOptions.Center;
|
||||
scoreTxt.color = Color.white;
|
||||
|
||||
// Continue button
|
||||
Button continueBtn = CreateAutoButton(panelRoot.transform, "Continue",
|
||||
new Vector2(0.25f, 0.1f), new Vector2(0.48f, 0.22f), new Color(0.2f, 0.7f, 0.3f));
|
||||
|
||||
// Restart button
|
||||
Button restartBtn = CreateAutoButton(panelRoot.transform, "Restart",
|
||||
new Vector2(0.52f, 0.1f), new Vector2(0.75f, 0.22f), new Color(0.8f, 0.4f, 0.1f));
|
||||
|
||||
MatchResultUI ui = panelRoot.AddComponent<MatchResultUI>();
|
||||
ui.resultHeaderText = headerTxt;
|
||||
ui.scoreText = scoreTxt;
|
||||
ui.continueButton = continueBtn;
|
||||
ui.restartButton = restartBtn;
|
||||
|
||||
DevLog.Log("[MatchResultUI] Auto-created minimal result panel on Canvas");
|
||||
}
|
||||
|
||||
private static Button CreateAutoButton(Transform parent, string label,
|
||||
Vector2 anchorMin, Vector2 anchorMax, Color bgColor)
|
||||
{
|
||||
GameObject btnObj = new GameObject(label + "Button");
|
||||
btnObj.transform.SetParent(parent, false);
|
||||
RectTransform brt = btnObj.AddComponent<RectTransform>();
|
||||
brt.anchorMin = anchorMin;
|
||||
brt.anchorMax = anchorMax;
|
||||
brt.offsetMin = Vector2.zero;
|
||||
brt.offsetMax = Vector2.zero;
|
||||
Image btnBg = btnObj.AddComponent<Image>();
|
||||
btnBg.color = bgColor;
|
||||
Button btn = btnObj.AddComponent<Button>();
|
||||
|
||||
GameObject btnLabelObj = new GameObject("Label");
|
||||
btnLabelObj.transform.SetParent(btnObj.transform, false);
|
||||
RectTransform lrt = btnLabelObj.AddComponent<RectTransform>();
|
||||
lrt.anchorMin = Vector2.zero;
|
||||
lrt.anchorMax = Vector2.one;
|
||||
lrt.offsetMin = Vector2.zero;
|
||||
lrt.offsetMax = Vector2.zero;
|
||||
TextMeshProUGUI txt = btnLabelObj.AddComponent<TextMeshProUGUI>();
|
||||
txt.text = label;
|
||||
txt.fontSize = 42;
|
||||
txt.alignment = TextAlignmentOptions.Center;
|
||||
txt.color = Color.white;
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_instance = this;
|
||||
if (rootPanel == null) rootPanel = gameObject;
|
||||
|
||||
_audio = GetComponent<AudioSource>();
|
||||
if (_audio == null) _audio = gameObject.AddComponent<AudioSource>();
|
||||
_audio.playOnAwake = false;
|
||||
_audio.spatialBlend = 0f;
|
||||
|
||||
_canvasGroup = rootPanel.GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) _canvasGroup = rootPanel.AddComponent<CanvasGroup>();
|
||||
|
||||
// Ensure MatchStatTracker exists to record stats from match start
|
||||
if (MatchStatTracker.Instance == null)
|
||||
{
|
||||
GameObject trackerObj = new GameObject("MatchStatTracker");
|
||||
trackerObj.AddComponent<MatchStatTracker>();
|
||||
}
|
||||
|
||||
// IMPORTANT: Do NOT use SetActive(false) — that disables the MonoBehaviour
|
||||
// and unsubscribes from GameEvents.OnMatchResult.
|
||||
// Instead hide via CanvasGroup so the script stays alive.
|
||||
HideImmediate();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnMatchResult += OnMatchResult;
|
||||
|
||||
// Wire button listeners
|
||||
WireButtonListeners();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnMatchResult -= OnMatchResult;
|
||||
|
||||
if (continueButton != null) continueButton.onClick.RemoveListener(OnContinueClicked);
|
||||
if (restartButton != null) restartButton.onClick.RemoveListener(OnRestartClicked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Late-wire buttons — handles auto-created panels where buttons
|
||||
/// are assigned after Awake/OnEnable.
|
||||
/// </summary>
|
||||
private void Start()
|
||||
{
|
||||
WireButtonListeners();
|
||||
}
|
||||
|
||||
private void WireButtonListeners()
|
||||
{
|
||||
if (continueButton != null)
|
||||
{
|
||||
continueButton.onClick.RemoveListener(OnContinueClicked);
|
||||
continueButton.onClick.AddListener(OnContinueClicked);
|
||||
}
|
||||
if (restartButton != null)
|
||||
{
|
||||
restartButton.onClick.RemoveListener(OnRestartClicked);
|
||||
restartButton.onClick.AddListener(OnRestartClicked);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event ───────────────────────────────────────────────
|
||||
|
||||
private void OnMatchResult(bool playerWon)
|
||||
{
|
||||
Show(playerWon);
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Show the result screen. Called automatically via GameEvents or manually.</summary>
|
||||
public void Show(bool playerWon)
|
||||
{
|
||||
DevLog.Log($"[MatchResultUI] Show(playerWon={playerWon})");
|
||||
rootPanel.SetActive(true);
|
||||
// Don't set alpha here — RevealRoutine fades from 0 to 1
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
|
||||
// Pause game so combat freezes behind the result panel
|
||||
_savedFixedDeltaTime = Time.fixedDeltaTime;
|
||||
Time.timeScale = 0f;
|
||||
// NOTE: Do NOT set fixedDeltaTime to 0 — timeScale=0 already pauses FixedUpdate.
|
||||
// Setting fixedDeltaTime=0 caused an infinite loop / hard freeze.
|
||||
|
||||
bool isCashSystem = SelectionOptions.Instance != null && SelectionOptions.Instance.isCashSystemSelected;
|
||||
|
||||
// ── Header ──
|
||||
if (resultHeaderText != null)
|
||||
{
|
||||
resultHeaderText.text = playerWon ? "VICTORY" : "DEFEAT";
|
||||
resultHeaderText.color = playerWon ? victoryColor : defeatColor;
|
||||
}
|
||||
|
||||
// ── Scores ──
|
||||
if (isCashSystem)
|
||||
PopulateCashSystemScores(playerWon);
|
||||
else
|
||||
PopulatePracticeScores(playerWon);
|
||||
|
||||
// ── Team labels ──
|
||||
PopulateTeamLabels(playerWon, isCashSystem);
|
||||
|
||||
// ── Stats ──
|
||||
if (isCashSystem)
|
||||
PopulateCashSystemStats();
|
||||
else
|
||||
PopulatePracticeStats();
|
||||
|
||||
// ── Sound ──
|
||||
AudioClip clip = playerWon ? victorySFX : defeatSFX;
|
||||
if (clip != null) _audio.PlayOneShot(clip, sfxVolume);
|
||||
|
||||
// ── Animate ──
|
||||
StartCoroutine(RevealRoutine());
|
||||
}
|
||||
|
||||
// ─── Cash System Stats ───────────────────────────────────
|
||||
|
||||
private void PopulateCashSystemScores(bool playerWon)
|
||||
{
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
float playerScore = 0f, enemyScore = 0f;
|
||||
if (mgr != null)
|
||||
{
|
||||
var pv = mgr.GetVault("Player");
|
||||
var ev = mgr.GetVault("Enemy");
|
||||
if (pv != null) playerScore = pv.StoredCash;
|
||||
if (ev != null) enemyScore = ev.StoredCash;
|
||||
}
|
||||
|
||||
if (scoreText != null)
|
||||
{
|
||||
string resultWord = playerWon ? "VICTORY" : "DEFEAT";
|
||||
scoreText.text = $"{playerScore:F0} {resultWord} {enemyScore:F0}";
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateCashSystemStats()
|
||||
{
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr == null) { ClearStats(true); return; }
|
||||
|
||||
MatchStatTracker tracker = MatchStatTracker.Instance;
|
||||
|
||||
var playerChars = mgr.GetTeamCharacters("Player");
|
||||
var enemyChars = mgr.GetTeamCharacters("Enemy");
|
||||
|
||||
// Get vault totals for the score header
|
||||
var pv = mgr.GetVault("Player");
|
||||
var ev = mgr.GetVault("Enemy");
|
||||
float playerVaultTotal = pv != null ? pv.StoredCash : 0f;
|
||||
float enemyVaultTotal = ev != null ? ev.StoredCash : 0f;
|
||||
|
||||
int pKills = 0;
|
||||
float pDeposited = 0f, pStolen = 0f, pCollected = 0f;
|
||||
int eKills = 0;
|
||||
float eDeposited = 0f, eStolen = 0f, eCollected = 0f;
|
||||
|
||||
if (tracker != null)
|
||||
{
|
||||
foreach (var s in tracker.GetTeamStats(playerChars))
|
||||
{
|
||||
pKills += s.kills;
|
||||
pDeposited += s.cashDeposited;
|
||||
pStolen += s.cashStolen;
|
||||
pCollected += s.cashCollected;
|
||||
}
|
||||
foreach (var s in tracker.GetTeamStats(enemyChars))
|
||||
{
|
||||
eKills += s.kills;
|
||||
eDeposited += s.cashDeposited;
|
||||
eStolen += s.cashStolen;
|
||||
eCollected += s.cashCollected;
|
||||
}
|
||||
}
|
||||
|
||||
// Player side — Cash System stats (4 text slots used smartly for cash data)
|
||||
SetStatText(playerNameText, "PLAYER TEAM");
|
||||
SetStatText(playerKillsText, $"Vault Total: ${playerVaultTotal:F0}");
|
||||
SetStatText(playerDeathsText, $"Cash Deposited: ${pDeposited:F0}");
|
||||
SetStatText(playerAssistsText, $"Cash Stolen: ${pStolen:F0}");
|
||||
SetStatText(playerDamageText, $"Eliminations: {pKills}");
|
||||
|
||||
// Enemy side
|
||||
SetStatText(enemyNameText, "ENEMY TEAM");
|
||||
SetStatText(enemyKillsText, $"Vault Total: ${enemyVaultTotal:F0}");
|
||||
SetStatText(enemyDeathsText, $"Cash Deposited: ${eDeposited:F0}");
|
||||
SetStatText(enemyAssistsText, $"Cash Stolen: ${eStolen:F0}");
|
||||
SetStatText(enemyDamageText, $"Eliminations: {eKills}");
|
||||
}
|
||||
|
||||
// ─── Practice / 1v1 Stats ────────────────────────────────
|
||||
|
||||
private void PopulatePracticeScores(bool playerWon)
|
||||
{
|
||||
if (scoreText != null)
|
||||
{
|
||||
scoreText.text = playerWon ? "VICTORY" : "DEFEAT";
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulatePracticeStats()
|
||||
{
|
||||
MatchStatTracker tracker = MatchStatTracker.Instance;
|
||||
|
||||
// In practice mode: find Player and Enemy tagged objects
|
||||
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
|
||||
GameObject enemyObj = GameObject.FindGameObjectWithTag("Enemy");
|
||||
|
||||
int pKills = 0, pDeaths = 0, pAssists = 0;
|
||||
float pDamage = 0f;
|
||||
int eKills = 0, eDeaths = 0, eAssists = 0;
|
||||
float eDamage = 0f;
|
||||
|
||||
if (tracker != null)
|
||||
{
|
||||
if (playerObj != null)
|
||||
{
|
||||
var s = tracker.GetStats(playerObj);
|
||||
pKills = s.kills;
|
||||
pDeaths = s.deaths;
|
||||
pAssists = s.assists;
|
||||
pDamage = s.damageDealt;
|
||||
}
|
||||
if (enemyObj != null)
|
||||
{
|
||||
var s = tracker.GetStats(enemyObj);
|
||||
eKills = s.kills;
|
||||
eDeaths = s.deaths;
|
||||
eAssists = s.assists;
|
||||
eDamage = s.damageDealt;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback from HealthNew
|
||||
if (playerObj != null) FallbackSingle(playerObj, out pKills, out pDeaths, out pDamage);
|
||||
if (enemyObj != null) FallbackSingle(enemyObj, out eKills, out eDeaths, out eDamage);
|
||||
}
|
||||
|
||||
// Get actual character display names for practice mode
|
||||
string playerName = GetCharacterDisplayName(playerObj, "PLAYER");
|
||||
string enemyName = GetCharacterDisplayName(enemyObj, "ENEMY");
|
||||
|
||||
// Player side — practice labels (K/D/A + Damage)
|
||||
SetStatText(playerNameText, playerName);
|
||||
SetStatText(playerKillsText, $"Kills: {pKills}");
|
||||
SetStatText(playerDeathsText, $"Deaths: {pDeaths}");
|
||||
SetStatText(playerAssistsText, $"Assists: {pAssists}");
|
||||
SetStatText(playerDamageText, $"Damage: {pDamage:F0}");
|
||||
|
||||
// Enemy side
|
||||
SetStatText(enemyNameText, enemyName);
|
||||
SetStatText(enemyKillsText, $"Kills: {eKills}");
|
||||
SetStatText(enemyDeathsText, $"Deaths: {eDeaths}");
|
||||
SetStatText(enemyAssistsText, $"Assists: {eAssists}");
|
||||
SetStatText(enemyDamageText, $"Damage: {eDamage:F0}");
|
||||
}
|
||||
|
||||
// ─── Common Helpers ──────────────────────────────────────
|
||||
|
||||
private void PopulateTeamLabels(bool playerWon, bool isCashSystem)
|
||||
{
|
||||
if (isCashSystem)
|
||||
{
|
||||
if (playerTeamLabel != null)
|
||||
playerTeamLabel.text = playerWon ? "PLAYER TEAM WIN!" : "PLAYER TEAM LOSE!";
|
||||
if (enemyTeamLabel != null)
|
||||
enemyTeamLabel.text = playerWon ? "ENEMY TEAM LOSE!" : "ENEMY TEAM WIN!";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Practice mode — use actual character names
|
||||
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
|
||||
GameObject enemyObj = GameObject.FindGameObjectWithTag("Enemy");
|
||||
string pName = GetCharacterDisplayName(playerObj, "YOU");
|
||||
string eName = GetCharacterDisplayName(enemyObj, "ENEMY");
|
||||
|
||||
if (playerTeamLabel != null)
|
||||
playerTeamLabel.text = playerWon ? $"{pName} WIN!" : $"{pName} LOSE!";
|
||||
if (enemyTeamLabel != null)
|
||||
enemyTeamLabel.text = playerWon ? $"{eName} LOSE!" : $"{eName} WIN!";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Get the display name for a character GameObject (e.g. "WINDHAM").</summary>
|
||||
private string GetCharacterDisplayName(GameObject character, string fallback)
|
||||
{
|
||||
if (character == null) return fallback;
|
||||
if (CharacterPrefabManager.Instance != null)
|
||||
{
|
||||
string name = CharacterPrefabManager.Instance.GetCharacterDisplayName(character);
|
||||
if (!string.IsNullOrEmpty(name)) return name.ToUpper();
|
||||
}
|
||||
return character.name.Replace("(Clone)", "").Trim().ToUpper();
|
||||
}
|
||||
|
||||
private void SetStatText(TextMeshProUGUI tmp, string value)
|
||||
{
|
||||
if (tmp != null) tmp.text = value;
|
||||
}
|
||||
|
||||
private void ClearStats(bool isCashSystem = false)
|
||||
{
|
||||
if (isCashSystem)
|
||||
{
|
||||
SetStatText(playerNameText, "PLAYER TEAM");
|
||||
SetStatText(playerKillsText, "Vault Total: $0");
|
||||
SetStatText(playerDeathsText, "Cash Deposited: $0");
|
||||
SetStatText(playerAssistsText, "Cash Stolen: $0");
|
||||
SetStatText(playerDamageText, "Eliminations: 0");
|
||||
SetStatText(enemyNameText, "ENEMY TEAM");
|
||||
SetStatText(enemyKillsText, "Vault Total: $0");
|
||||
SetStatText(enemyDeathsText, "Cash Deposited: $0");
|
||||
SetStatText(enemyAssistsText, "Cash Stolen: $0");
|
||||
SetStatText(enemyDamageText, "Eliminations: 0");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatText(playerNameText, "PLAYER");
|
||||
SetStatText(playerKillsText, "Kills: 0");
|
||||
SetStatText(playerDeathsText, "Deaths: 0");
|
||||
SetStatText(playerAssistsText, "Assists: 0");
|
||||
SetStatText(playerDamageText, "Damage: 0");
|
||||
SetStatText(enemyNameText, "ENEMY");
|
||||
SetStatText(enemyKillsText, "Kills: 0");
|
||||
SetStatText(enemyDeathsText, "Deaths: 0");
|
||||
SetStatText(enemyAssistsText, "Assists: 0");
|
||||
SetStatText(enemyDamageText, "Damage: 0");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fallback stats from HealthNew for a team list.</summary>
|
||||
private void FallbackStats(List<GameObject> team, out int kills, out int deaths, out float damage)
|
||||
{
|
||||
kills = 0; deaths = 0; damage = 0f;
|
||||
if (team == null) return;
|
||||
foreach (var c in team)
|
||||
{
|
||||
if (c == null) continue;
|
||||
var h = c.GetComponent<HealthNew>();
|
||||
if (h == null) continue;
|
||||
if (h.isDead) deaths++;
|
||||
damage += Mathf.Max(0, h.maxHealth - h.currentHealth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fallback stats from HealthNew for a single character.</summary>
|
||||
private void FallbackSingle(GameObject character, out int kills, out int deaths, out float damage)
|
||||
{
|
||||
kills = 0; deaths = 0; damage = 0f;
|
||||
if (character == null) return;
|
||||
var h = character.GetComponent<HealthNew>();
|
||||
if (h == null) return;
|
||||
if (h.isDead) deaths = 1;
|
||||
damage = Mathf.Max(0, h.maxHealth - h.currentHealth);
|
||||
}
|
||||
|
||||
/// <summary>Hide panel via CanvasGroup — keeps MonoBehaviour alive.</summary>
|
||||
private void HideImmediate()
|
||||
{
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
}
|
||||
|
||||
// ─── Animation ───────────────────────────────────────────
|
||||
|
||||
private IEnumerator RevealRoutine()
|
||||
{
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = true;
|
||||
_canvasGroup.interactable = true;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < revealDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
_canvasGroup.alpha = Mathf.Clamp01(elapsed / revealDuration);
|
||||
yield return null;
|
||||
}
|
||||
_canvasGroup.alpha = 1f;
|
||||
}
|
||||
|
||||
// ─── Button Callbacks ────────────────────────────────────
|
||||
|
||||
private void OnContinueClicked()
|
||||
{
|
||||
PlayButtonClick();
|
||||
|
||||
// Restore time before scene transition
|
||||
Time.timeScale = 1f;
|
||||
Time.fixedDeltaTime = _savedFixedDeltaTime;
|
||||
GameEvents.ClearAll();
|
||||
|
||||
GameStatsManager statsManager = FindObjectOfType<GameStatsManager>();
|
||||
if (statsManager != null)
|
||||
statsManager.match_end();
|
||||
else
|
||||
UnityEngine.SceneManagement.SceneManager.LoadScene("MenuScene");
|
||||
}
|
||||
|
||||
private void OnRestartClicked()
|
||||
{
|
||||
PlayButtonClick();
|
||||
|
||||
// Restore time before scene transition
|
||||
Time.timeScale = 1f;
|
||||
Time.fixedDeltaTime = _savedFixedDeltaTime;
|
||||
GameEvents.ClearAll();
|
||||
|
||||
// Mode flags are preserved on SelectionOptions (DontDestroyOnLoad)
|
||||
UnityEngine.SceneManagement.SceneManager.LoadScene(
|
||||
UnityEngine.SceneManagement.SceneManager.GetActiveScene().name);
|
||||
}
|
||||
|
||||
private void PlayButtonClick()
|
||||
{
|
||||
if (buttonClickSFX != null && _audio != null)
|
||||
_audio.PlayOneShot(buttonClickSFX, sfxVolume);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/UI/MatchResultUI.cs.meta
Normal file
2
Assets/Scripts/CashSystem/UI/MatchResultUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5fcb73e900cc542498962703b55def9e
|
||||
222
Assets/Scripts/CashSystem/UI/MatchStatTracker.cs
Normal file
222
Assets/Scripts/CashSystem/UI/MatchStatTracker.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// MatchStatTracker — Listens to GameEvents during a match and records
|
||||
/// per-character stats (kills, deaths, assists, damage dealt, cash deposited, cash stolen).
|
||||
/// Singleton that lives for the duration of the match scene.
|
||||
/// MatchResultUI reads from this at match end.
|
||||
/// </summary>
|
||||
public class MatchStatTracker : MonoBehaviour
|
||||
{
|
||||
/// <summary>Stats for one character.</summary>
|
||||
public class CharacterStats
|
||||
{
|
||||
public string name;
|
||||
public int kills;
|
||||
public int deaths;
|
||||
public int assists;
|
||||
public float damageDealt;
|
||||
public float cashDeposited;
|
||||
public float cashStolen;
|
||||
public float cashCollected;
|
||||
}
|
||||
|
||||
// Key = character GameObject instance ID
|
||||
private Dictionary<int, CharacterStats> _stats = new Dictionary<int, CharacterStats>();
|
||||
|
||||
// Track recent attackers for assist detection (victimID → list of attackerIDs within last N seconds)
|
||||
private Dictionary<int, List<(int attackerID, float time)>> _recentAttackers
|
||||
= new Dictionary<int, List<(int, float)>>();
|
||||
private const float ASSIST_WINDOW = 5f;
|
||||
|
||||
private static MatchStatTracker _instance;
|
||||
public static MatchStatTracker Instance => _instance;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnKill += OnKill;
|
||||
GameEvents.OnDamageDealt += OnDamageDealt;
|
||||
GameEvents.OnCharacterDied += OnCharacterDied;
|
||||
GameEvents.OnCashDeposited += OnCashDeposited;
|
||||
GameEvents.OnVaultStolen += OnVaultStolen;
|
||||
GameEvents.OnBagPickedUp += OnBagPickedUp;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnKill -= OnKill;
|
||||
GameEvents.OnDamageDealt -= OnDamageDealt;
|
||||
GameEvents.OnCharacterDied -= OnCharacterDied;
|
||||
GameEvents.OnCashDeposited -= OnCashDeposited;
|
||||
GameEvents.OnVaultStolen -= OnVaultStolen;
|
||||
GameEvents.OnBagPickedUp -= OnBagPickedUp;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Get stats for a specific character. Creates entry if missing.</summary>
|
||||
public CharacterStats GetStats(GameObject character)
|
||||
{
|
||||
if (character == null) return new CharacterStats { name = "Unknown" };
|
||||
int id = character.GetInstanceID();
|
||||
if (!_stats.ContainsKey(id))
|
||||
{
|
||||
_stats[id] = new CharacterStats
|
||||
{
|
||||
name = character.name.Replace("(Clone)", "").Trim()
|
||||
};
|
||||
}
|
||||
return _stats[id];
|
||||
}
|
||||
|
||||
/// <summary>Get stats for all characters in a list (team).</summary>
|
||||
public List<CharacterStats> GetTeamStats(List<GameObject> teamCharacters)
|
||||
{
|
||||
List<CharacterStats> result = new List<CharacterStats>();
|
||||
if (teamCharacters == null) return result;
|
||||
foreach (var c in teamCharacters)
|
||||
{
|
||||
if (c != null) result.Add(GetStats(c));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Event Handlers ──────────────────────────────────────
|
||||
|
||||
private void OnKill(GameObject killer, GameObject victim)
|
||||
{
|
||||
if (killer != null)
|
||||
GetStats(killer).kills++;
|
||||
if (victim != null)
|
||||
GetStats(victim).deaths++;
|
||||
|
||||
// Award assists to other recent attackers of the victim
|
||||
if (victim != null)
|
||||
{
|
||||
int victimID = victim.GetInstanceID();
|
||||
int killerID = killer != null ? killer.GetInstanceID() : -1;
|
||||
|
||||
if (_recentAttackers.ContainsKey(victimID))
|
||||
{
|
||||
HashSet<int> assistedIDs = new HashSet<int>();
|
||||
foreach (var (attackerID, time) in _recentAttackers[victimID])
|
||||
{
|
||||
if (attackerID != killerID && Time.time - time <= ASSIST_WINDOW && !assistedIDs.Contains(attackerID))
|
||||
{
|
||||
assistedIDs.Add(attackerID);
|
||||
if (_stats.ContainsKey(attackerID))
|
||||
_stats[attackerID].assists++;
|
||||
}
|
||||
}
|
||||
_recentAttackers.Remove(victimID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDamageDealt(GameObject attacker, GameObject victim, float amount)
|
||||
{
|
||||
if (attacker != null)
|
||||
GetStats(attacker).damageDealt += amount;
|
||||
|
||||
// Track for assists
|
||||
if (attacker != null && victim != null)
|
||||
{
|
||||
int victimID = victim.GetInstanceID();
|
||||
int attackerID = attacker.GetInstanceID();
|
||||
|
||||
if (!_recentAttackers.ContainsKey(victimID))
|
||||
_recentAttackers[victimID] = new List<(int, float)>();
|
||||
|
||||
_recentAttackers[victimID].Add((attackerID, Time.time));
|
||||
|
||||
// Prune old entries
|
||||
_recentAttackers[victimID].RemoveAll(e => Time.time - e.time > ASSIST_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCharacterDied(GameObject character)
|
||||
{
|
||||
// Death is already tracked via OnKill, but if OnKill isn't fired for some deaths:
|
||||
if (character != null)
|
||||
{
|
||||
var s = GetStats(character);
|
||||
// Only increment if OnKill hasn't already done it this frame
|
||||
// (simple guard — check if death count was already incremented)
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCashDeposited(string teamTag, float value, int combo)
|
||||
{
|
||||
// Attribute to the character who deposited — find the closest carrier
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr == null) return;
|
||||
|
||||
var team = mgr.GetTeamCharacters(teamTag);
|
||||
if (team == null) return;
|
||||
|
||||
// Find the character who just deposited (nearest to their vault)
|
||||
TeamVault vault = mgr.GetVault(teamTag);
|
||||
if (vault == null) return;
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
GameObject depositor = null;
|
||||
foreach (var c in team)
|
||||
{
|
||||
if (c == null) continue;
|
||||
float d = Vector3.Distance(c.transform.position, vault.transform.position);
|
||||
if (d < bestDist)
|
||||
{
|
||||
bestDist = d;
|
||||
depositor = c;
|
||||
}
|
||||
}
|
||||
|
||||
if (depositor != null)
|
||||
GetStats(depositor).cashDeposited += value;
|
||||
}
|
||||
|
||||
private void OnVaultStolen(TeamVault vault, float amount)
|
||||
{
|
||||
// Attribute to the closest enemy character to this vault
|
||||
if (vault == null) return;
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr == null) return;
|
||||
|
||||
string enemyTag = vault.TeamTag == "Player" ? "Enemy" : "Player";
|
||||
var enemies = mgr.GetTeamCharacters(enemyTag);
|
||||
if (enemies == null) return;
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
GameObject thief = null;
|
||||
foreach (var c in enemies)
|
||||
{
|
||||
if (c == null) continue;
|
||||
float d = Vector3.Distance(c.transform.position, vault.transform.position);
|
||||
if (d < bestDist)
|
||||
{
|
||||
bestDist = d;
|
||||
thief = c;
|
||||
}
|
||||
}
|
||||
|
||||
if (thief != null)
|
||||
GetStats(thief).cashStolen += amount;
|
||||
}
|
||||
|
||||
private void OnBagPickedUp(CashBag bag, CashCarrier carrier)
|
||||
{
|
||||
if (carrier != null && carrier.gameObject != null)
|
||||
GetStats(carrier.gameObject).cashCollected += bag != null ? bag.CashValue : 0f;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/UI/MatchStatTracker.cs.meta
Normal file
2
Assets/Scripts/CashSystem/UI/MatchStatTracker.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d965cc3b37bf114f1a94af15fdcd2151
|
||||
429
Assets/Scripts/CashSystem/UI/PreMatchIntroUI.cs
Normal file
429
Assets/Scripts/CashSystem/UI/PreMatchIntroUI.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// PreMatchIntroUI — "3 vs 3" matchup splash + countdown before gameplay.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Show team info (displayDuration seconds)
|
||||
/// 2. Countdown: 3 → 2 → 1 → FIGHT!
|
||||
/// 3. Fade out, set IsComplete = true
|
||||
/// 4. CashSystemManager waits for IsComplete before enabling combat
|
||||
///
|
||||
/// Assign portrait Image slots in the Inspector. The script loads character
|
||||
/// sprites into those Image components by team index.
|
||||
///
|
||||
/// Press Space (or skipButton) to skip intro → fast countdown then complete.
|
||||
///
|
||||
/// Uses CanvasGroup to hide (NOT SetActive(false)) so the script stays alive.
|
||||
/// </summary>
|
||||
public class PreMatchIntroUI : MonoBehaviour
|
||||
{
|
||||
[Header("Panel")]
|
||||
[SerializeField] private GameObject rootPanel;
|
||||
|
||||
[Header("Player Team (Left) — Assign portrait Image slots")]
|
||||
[SerializeField] private Image[] playerPortraits = new Image[3];
|
||||
[SerializeField] private TextMeshProUGUI[] playerNames = new TextMeshProUGUI[3];
|
||||
|
||||
[Header("Enemy Team (Right) — Assign portrait Image slots")]
|
||||
[SerializeField] private Image[] enemyPortraits = new Image[3];
|
||||
[SerializeField] private TextMeshProUGUI[] enemyNames = new TextMeshProUGUI[3];
|
||||
|
||||
[Header("Centre")]
|
||||
[SerializeField] private TextMeshProUGUI vsText;
|
||||
|
||||
[Header("Countdown")]
|
||||
[Tooltip("Text element for countdown (3, 2, 1, FIGHT!)")]
|
||||
[SerializeField] private TextMeshProUGUI countdownText;
|
||||
|
||||
[Header("Skip")]
|
||||
[Tooltip("Optional skip button — user can also press Space")]
|
||||
[SerializeField] private Button skipButton;
|
||||
[Tooltip("Text shown at bottom: 'Press Space to Skip'")]
|
||||
[SerializeField] private TextMeshProUGUI skipHintText;
|
||||
|
||||
[Header("Game Mode Info (optional)")]
|
||||
[SerializeField] private TextMeshProUGUI modeTitle;
|
||||
[SerializeField] private TextMeshProUGUI objectiveText;
|
||||
|
||||
[Header("Timing")]
|
||||
[SerializeField] private float displayDuration = 3.5f;
|
||||
[SerializeField] private float fadeInDuration = 0.4f;
|
||||
[SerializeField] private float fadeOutDuration = 0.5f;
|
||||
[SerializeField] private float countdownStepTime = 1f;
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private AudioClip introSFX;
|
||||
[SerializeField] private AudioClip countdownBeepSFX;
|
||||
[SerializeField] private AudioClip fightSFX;
|
||||
[SerializeField] private float sfxVolume = 1f;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color playerTeamColor = new Color(0.13f, 0.59f, 0.95f);
|
||||
[SerializeField] private Color enemyTeamColor = new Color(0.96f, 0.26f, 0.21f);
|
||||
[SerializeField] private Color countdownColor = Color.white;
|
||||
[SerializeField] private Color fightColor = new Color(1f, 0.84f, 0f); // Gold
|
||||
|
||||
// runtime
|
||||
private AudioSource _audio;
|
||||
private CanvasGroup _canvasGroup;
|
||||
private Coroutine _sequenceCoroutine;
|
||||
private bool _skipRequested;
|
||||
|
||||
/// <summary>True once intro sequence (including countdown) is fully done.</summary>
|
||||
public bool IsComplete { get; private set; }
|
||||
|
||||
private static PreMatchIntroUI _instance;
|
||||
public static PreMatchIntroUI Instance => _instance;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a PreMatchIntroUI instance exists in the scene.
|
||||
/// Finds inactive instances first, or creates a minimal one on the existing Canvas.
|
||||
/// Call from CashSystemManager.Start() so the intro always works.
|
||||
/// </summary>
|
||||
public static void EnsureExists()
|
||||
{
|
||||
if (_instance != null) return;
|
||||
|
||||
// 1. Try to find an inactive instance already in the scene
|
||||
PreMatchIntroUI[] all = Resources.FindObjectsOfTypeAll<PreMatchIntroUI>();
|
||||
foreach (var p in all)
|
||||
{
|
||||
// Skip assets (prefabs) — only want scene objects
|
||||
if (p.gameObject.scene.name == null) continue;
|
||||
// Activate parents top-down first, then the object itself
|
||||
// (Awake only fires when the ENTIRE chain is active)
|
||||
List<GameObject> chain = new List<GameObject>();
|
||||
Transform t = p.transform;
|
||||
while (t != null)
|
||||
{
|
||||
if (!t.gameObject.activeSelf)
|
||||
chain.Add(t.gameObject);
|
||||
t = t.parent;
|
||||
}
|
||||
// Activate top-down
|
||||
for (int i = chain.Count - 1; i >= 0; i--)
|
||||
chain[i].SetActive(true);
|
||||
|
||||
if (_instance != null)
|
||||
{
|
||||
Debug.Log("[PreMatchIntroUI] Found existing inactive panel, activated it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create a minimal panel on an existing Canvas
|
||||
Canvas canvas = Object.FindObjectOfType<Canvas>();
|
||||
if (canvas == null)
|
||||
{
|
||||
GameObject canvasObj = new GameObject("UICanvas");
|
||||
canvas = canvasObj.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 100;
|
||||
canvasObj.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
canvasObj.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
}
|
||||
|
||||
GameObject panelRoot = new GameObject("PreMatchIntroUI_Auto");
|
||||
panelRoot.transform.SetParent(canvas.transform, false);
|
||||
|
||||
// Stretch to fill canvas
|
||||
RectTransform rt = panelRoot.AddComponent<RectTransform>();
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
|
||||
// Background image (dark overlay)
|
||||
Image bg = panelRoot.AddComponent<Image>();
|
||||
bg.color = new Color(0.05f, 0.05f, 0.1f, 0.85f);
|
||||
|
||||
// Countdown text in centre
|
||||
GameObject textObj = new GameObject("CountdownText");
|
||||
textObj.transform.SetParent(panelRoot.transform, false);
|
||||
RectTransform trt = textObj.AddComponent<RectTransform>();
|
||||
trt.anchorMin = new Vector2(0.3f, 0.3f);
|
||||
trt.anchorMax = new Vector2(0.7f, 0.7f);
|
||||
trt.offsetMin = Vector2.zero;
|
||||
trt.offsetMax = Vector2.zero;
|
||||
TextMeshProUGUI txt = textObj.AddComponent<TextMeshProUGUI>();
|
||||
txt.text = "";
|
||||
txt.fontSize = 120;
|
||||
txt.alignment = TextAlignmentOptions.Center;
|
||||
txt.color = Color.white;
|
||||
|
||||
// VS text
|
||||
GameObject vsObj = new GameObject("VSText");
|
||||
vsObj.transform.SetParent(panelRoot.transform, false);
|
||||
RectTransform vrt = vsObj.AddComponent<RectTransform>();
|
||||
vrt.anchorMin = new Vector2(0.35f, 0.55f);
|
||||
vrt.anchorMax = new Vector2(0.65f, 0.75f);
|
||||
vrt.offsetMin = Vector2.zero;
|
||||
vrt.offsetMax = Vector2.zero;
|
||||
TextMeshProUGUI vsTxt = vsObj.AddComponent<TextMeshProUGUI>();
|
||||
vsTxt.text = "VS";
|
||||
vsTxt.fontSize = 80;
|
||||
vsTxt.alignment = TextAlignmentOptions.Center;
|
||||
vsTxt.color = Color.yellow;
|
||||
|
||||
PreMatchIntroUI intro = panelRoot.AddComponent<PreMatchIntroUI>();
|
||||
intro.countdownText = txt;
|
||||
intro.vsText = vsTxt;
|
||||
|
||||
Debug.Log("[PreMatchIntroUI] Auto-created minimal intro panel on Canvas");
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_instance = this;
|
||||
if (rootPanel == null) rootPanel = gameObject;
|
||||
|
||||
_audio = GetComponent<AudioSource>();
|
||||
if (_audio == null) _audio = gameObject.AddComponent<AudioSource>();
|
||||
_audio.playOnAwake = false;
|
||||
_audio.spatialBlend = 0f;
|
||||
|
||||
_canvasGroup = rootPanel.GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) _canvasGroup = rootPanel.AddComponent<CanvasGroup>();
|
||||
|
||||
// Hide via CanvasGroup — keep MonoBehaviour alive for Show() calls
|
||||
HideImmediate();
|
||||
|
||||
// Wire skip button
|
||||
if (skipButton != null)
|
||||
skipButton.onClick.AddListener(Skip);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Space key skip
|
||||
if (!IsComplete && Input.GetKeyDown(KeyCode.Space))
|
||||
_skipRequested = true;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
public void Show()
|
||||
{
|
||||
Debug.Log("[PreMatchIntroUI] Show() called");
|
||||
IsComplete = false;
|
||||
_skipRequested = false;
|
||||
|
||||
rootPanel.SetActive(true);
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = true;
|
||||
_canvasGroup.interactable = true;
|
||||
|
||||
// Populate from SelectionOptions
|
||||
string[] playerTeam = SelectionOptions.Instance != null ? SelectionOptions.Instance.playerTeam : null;
|
||||
string[] enemyTeam = SelectionOptions.Instance != null ? SelectionOptions.Instance.enemyTeam : null;
|
||||
|
||||
PopulateSide(playerTeam, playerPortraits, playerNames, playerTeamColor);
|
||||
PopulateSide(enemyTeam, enemyPortraits, enemyNames, enemyTeamColor);
|
||||
|
||||
if (vsText != null) vsText.text = "VS";
|
||||
|
||||
// Game mode info
|
||||
if (modeTitle != null) modeTitle.text = "CASH SYSTEM";
|
||||
if (objectiveText != null) objectiveText.text = "Collect cash and deposit it in your vault!";
|
||||
|
||||
// Skip hint
|
||||
if (skipHintText != null) skipHintText.text = "Press Space to Skip";
|
||||
|
||||
// Clear countdown at start
|
||||
if (countdownText != null) countdownText.text = "";
|
||||
|
||||
// Play intro sound
|
||||
if (introSFX != null) _audio.PlayOneShot(introSFX, sfxVolume);
|
||||
|
||||
// Start the full intro sequence (fade in → display → countdown → fade out)
|
||||
if (_sequenceCoroutine != null) StopCoroutine(_sequenceCoroutine);
|
||||
_sequenceCoroutine = StartCoroutine(IntroSequence());
|
||||
}
|
||||
|
||||
/// <summary>Force hide — called if CashSystemManager needs to tear down early.</summary>
|
||||
public void Hide()
|
||||
{
|
||||
if (_sequenceCoroutine != null) StopCoroutine(_sequenceCoroutine);
|
||||
HideImmediate();
|
||||
IsComplete = true;
|
||||
}
|
||||
|
||||
/// <summary>Skip the intro — jumps to fast countdown then completes.</summary>
|
||||
public void Skip()
|
||||
{
|
||||
_skipRequested = true;
|
||||
}
|
||||
|
||||
// ─── Intro Sequence ──────────────────────────────────────
|
||||
|
||||
private IEnumerator IntroSequence()
|
||||
{
|
||||
// Phase 1: Fade in
|
||||
yield return StartCoroutine(FadeIn());
|
||||
|
||||
// Phase 2: Display team info for displayDuration (or until skip)
|
||||
float elapsed = 0f;
|
||||
while (elapsed < displayDuration && !_skipRequested)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Phase 3: Countdown (3, 2, 1, FIGHT!)
|
||||
// If skip was requested, do a fast countdown
|
||||
float stepTime = _skipRequested ? 0.25f : countdownStepTime;
|
||||
|
||||
// Hide skip hint during countdown
|
||||
if (skipHintText != null) skipHintText.text = "";
|
||||
|
||||
string[] steps = { "3", "2", "1", "FIGHT!" };
|
||||
foreach (string step in steps)
|
||||
{
|
||||
if (countdownText != null)
|
||||
{
|
||||
countdownText.text = step;
|
||||
countdownText.color = (step == "FIGHT!") ? fightColor : countdownColor;
|
||||
|
||||
// Scale punch — make text pop
|
||||
countdownText.transform.localScale = Vector3.one * 1.5f;
|
||||
float scaleTimer = 0f;
|
||||
float scaleDuration = stepTime * 0.6f;
|
||||
while (scaleTimer < scaleDuration)
|
||||
{
|
||||
scaleTimer += Time.unscaledDeltaTime;
|
||||
float t = Mathf.Clamp01(scaleTimer / scaleDuration);
|
||||
countdownText.transform.localScale = Vector3.Lerp(
|
||||
Vector3.one * 1.5f, Vector3.one, t);
|
||||
yield return null;
|
||||
}
|
||||
countdownText.transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
// Fire event so other systems (like existing countdown UI) can react
|
||||
GameEvents.FireCountdownTick(step);
|
||||
|
||||
// SFX
|
||||
if (step == "FIGHT!")
|
||||
{
|
||||
if (fightSFX != null) _audio.PlayOneShot(fightSFX, sfxVolume);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (countdownBeepSFX != null) _audio.PlayOneShot(countdownBeepSFX, sfxVolume);
|
||||
}
|
||||
|
||||
// If skipping again during countdown, accelerate further
|
||||
if (_skipRequested) stepTime = 0.15f;
|
||||
|
||||
yield return new WaitForSecondsRealtime(Mathf.Max(0.05f, stepTime - (stepTime * 0.6f)));
|
||||
}
|
||||
|
||||
// Brief hold on "FIGHT!" before fading
|
||||
yield return new WaitForSecondsRealtime(_skipRequested ? 0.2f : 0.8f);
|
||||
|
||||
// Clear countdown text
|
||||
if (countdownText != null) countdownText.text = "";
|
||||
GameEvents.FireCountdownTick("");
|
||||
|
||||
// Phase 4: Fade out
|
||||
yield return StartCoroutine(FadeOut());
|
||||
|
||||
// Done — CashSystemManager checks this to enable combat
|
||||
IsComplete = true;
|
||||
Debug.Log("[PreMatchIntroUI] Intro complete");
|
||||
}
|
||||
|
||||
// ─── Population ──────────────────────────────────────────
|
||||
|
||||
private void PopulateSide(string[] names, Image[] portraits, TextMeshProUGUI[] labels, Color teamColor)
|
||||
{
|
||||
bool isPlayerSide = (portraits == playerPortraits);
|
||||
string sideLabel = isPlayerSide ? "Player" : "Enemy";
|
||||
|
||||
// Get portrait sprites stored from the selection screen (via SelectionOptions)
|
||||
Sprite[] teamSprites = null;
|
||||
if (SelectionOptions.Instance != null)
|
||||
teamSprites = isPlayerSide
|
||||
? SelectionOptions.Instance.playerTeamSprites
|
||||
: SelectionOptions.Instance.enemyTeamSprites;
|
||||
|
||||
Debug.Log($"[PreMatchIntroUI] PopulateSide({sideLabel}) — names: {(names != null ? string.Join(", ", names) : "null")}, sprites: {(teamSprites != null ? teamSprites.Length.ToString() : "null")}");
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
bool hasChar = names != null && i < names.Length && !string.IsNullOrEmpty(names[i]);
|
||||
|
||||
if (portraits != null && i < portraits.Length && portraits[i] != null)
|
||||
{
|
||||
if (hasChar)
|
||||
{
|
||||
// Use the sprite stored from the selection screen
|
||||
Sprite portrait = (teamSprites != null && i < teamSprites.Length) ? teamSprites[i] : null;
|
||||
|
||||
if (portrait != null)
|
||||
{
|
||||
portraits[i].sprite = portrait;
|
||||
portraits[i].color = Color.white;
|
||||
portraits[i].preserveAspect = true;
|
||||
Debug.Log($"[PreMatchIntroUI] {sideLabel}[{i}] portrait set for {names[i]} ({portrait.name})");
|
||||
}
|
||||
else
|
||||
{
|
||||
portraits[i].color = teamColor * 0.6f;
|
||||
Debug.LogWarning($"[PreMatchIntroUI] {sideLabel}[{i}] no portrait for {names[i]}, using team color");
|
||||
}
|
||||
portraits[i].gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
portraits[i].gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (labels != null && i < labels.Length && labels[i] != null)
|
||||
{
|
||||
labels[i].text = hasChar ? names[i].ToUpper() : "";
|
||||
labels[i].color = teamColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fade / Hide ─────────────────────────────────────────
|
||||
|
||||
private void HideImmediate()
|
||||
{
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
}
|
||||
|
||||
private IEnumerator FadeIn()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
while (elapsed < fadeInDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
_canvasGroup.alpha = Mathf.Clamp01(elapsed / fadeInDuration);
|
||||
yield return null;
|
||||
}
|
||||
_canvasGroup.alpha = 1f;
|
||||
}
|
||||
|
||||
private IEnumerator FadeOut()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float startAlpha = _canvasGroup.alpha;
|
||||
while (elapsed < fadeOutDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
_canvasGroup.alpha = Mathf.Lerp(startAlpha, 0f, elapsed / fadeOutDuration);
|
||||
yield return null;
|
||||
}
|
||||
HideImmediate();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/UI/PreMatchIntroUI.cs.meta
Normal file
2
Assets/Scripts/CashSystem/UI/PreMatchIntroUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 124c27138f4bba3059796c7a104e7b04
|
||||
368
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs
Normal file
368
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs
Normal file
@@ -0,0 +1,368 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// TeamHUDPanel — FRAG-style circular portrait HUD showing team health and character switching.
|
||||
///
|
||||
/// SETUP (Inspector):
|
||||
/// 1. Create your HUD layout manually — place circular Image slots wherever you want.
|
||||
/// 2. Assign them into the lists below:
|
||||
/// - characterPortraits[] — Image where the character icon/portrait sprite will go
|
||||
/// - healthDialImages[] — Image used as a radial-fill health ring around each portrait
|
||||
/// - dialBackgrounds[] — (optional) background ring images behind the health dials
|
||||
/// - nameLabels[] — (optional) TMP text for character names
|
||||
/// 3. The script fills sprites and updates health dials every frame.
|
||||
/// 4. Click/tap a portrait to switch control to that character.
|
||||
///
|
||||
/// Uses CanvasGroup to hide initially — stays alive for Initialize() call.
|
||||
/// </summary>
|
||||
public class TeamHUDPanel : MonoBehaviour
|
||||
{
|
||||
[Header("Character Portraits (assign in order: index 0 = team slot 0, etc.)")]
|
||||
[Tooltip("Image components where character icon sprites will be placed")]
|
||||
[SerializeField] private List<Image> characterPortraits = new List<Image>();
|
||||
|
||||
[Header("Health Dial Rings (same index order as portraits)")]
|
||||
[Tooltip("Image components used as radial-fill health rings — set to Filled/Radial360 at runtime")]
|
||||
[SerializeField] private List<Image> healthDialImages = new List<Image>();
|
||||
|
||||
[Header("Dial Backgrounds (optional, same index order)")]
|
||||
[Tooltip("Background ring images behind each health dial")]
|
||||
[SerializeField] private List<Image> dialBackgrounds = new List<Image>();
|
||||
|
||||
[Header("Name Labels (optional, same index order)")]
|
||||
[SerializeField] private List<TextMeshProUGUI> nameLabels = new List<TextMeshProUGUI>();
|
||||
|
||||
[Header("Dead Overlay (optional, same index order)")]
|
||||
[Tooltip("GameObjects to show when a character is dead (e.g. a dark overlay with X)")]
|
||||
[SerializeField] private List<GameObject> deadOverlays = new List<GameObject>();
|
||||
|
||||
[Header("Highlight Border (optional, same index order)")]
|
||||
[Tooltip("Image used as a highlight border — only visible on the active character")]
|
||||
[SerializeField] private List<Image> highlightBorders = new List<Image>();
|
||||
|
||||
[Header("Dial Sprite")]
|
||||
[Tooltip("Sprite for the health dial ring. If empty, tries to copy from ImportUIPanel's ProgressDial.")]
|
||||
[SerializeField] private Sprite dialSprite;
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private AudioClip switchSFX;
|
||||
[SerializeField] private AudioClip buttonHoverSFX;
|
||||
[SerializeField] private float sfxVolume = 1f;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color healthFullColor = new Color(0.30f, 0.87f, 0.30f, 1f);
|
||||
[SerializeField] private Color healthLowColor = new Color(0.96f, 0.26f, 0.21f, 1f);
|
||||
[SerializeField] private Color highlightColor = new Color(1f, 0.84f, 0f, 1f);
|
||||
[SerializeField] private Color defaultBorderColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
|
||||
|
||||
// runtime
|
||||
private List<GameObject> _characters = new List<GameObject>();
|
||||
private List<HealthNew> _healthComps = new List<HealthNew>();
|
||||
private int _currentHighlight = -1;
|
||||
private CanvasGroup _canvasGroup;
|
||||
private AudioSource _audio;
|
||||
|
||||
private static TeamHUDPanel _instance;
|
||||
public static TeamHUDPanel Instance => _instance;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_instance = this;
|
||||
|
||||
_canvasGroup = GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) _canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
_audio = GetComponent<AudioSource>();
|
||||
if (_audio == null) _audio = gameObject.AddComponent<AudioSource>();
|
||||
_audio.playOnAwake = false;
|
||||
_audio.spatialBlend = 0f;
|
||||
|
||||
// Hide via CanvasGroup until Initialize is called
|
||||
HideImmediate();
|
||||
|
||||
// Try to find dial sprite from ImportUIPanel if not assigned
|
||||
if (dialSprite == null)
|
||||
{
|
||||
var importPanel = GameObject.Find("ImportUIPanel");
|
||||
if (importPanel != null)
|
||||
{
|
||||
var dial = importPanel.transform.Find("ProgressDial");
|
||||
if (dial != null)
|
||||
{
|
||||
var img = dial.GetComponent<Image>();
|
||||
if (img != null) dialSprite = img.sprite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnCharacterSwitched += OnCharacterSwitched;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnCharacterSwitched -= OnCharacterSwitched;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the HUD with the player team characters.
|
||||
/// Call after characters are spawned (from CashSystemManager.SpawnTeams).
|
||||
/// </summary>
|
||||
public void Initialize(List<GameObject> playerTeamCharacters, int currentControlledIndex)
|
||||
{
|
||||
DevLog.Log($"[TeamHUDPanel] Initialize with {playerTeamCharacters?.Count ?? 0} characters, active={currentControlledIndex}");
|
||||
|
||||
_characters.Clear();
|
||||
_healthComps.Clear();
|
||||
|
||||
if (playerTeamCharacters == null || playerTeamCharacters.Count == 0) return;
|
||||
|
||||
int count = Mathf.Min(playerTeamCharacters.Count, characterPortraits.Count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
GameObject character = playerTeamCharacters[i];
|
||||
_characters.Add(character);
|
||||
_healthComps.Add(character != null ? character.GetComponent<HealthNew>() : null);
|
||||
|
||||
// Set portrait sprite
|
||||
if (i < characterPortraits.Count && characterPortraits[i] != null && character != null)
|
||||
{
|
||||
Sprite icon = GetCharacterIcon(character);
|
||||
if (icon != null)
|
||||
{
|
||||
characterPortraits[i].sprite = icon;
|
||||
characterPortraits[i].color = Color.white;
|
||||
characterPortraits[i].preserveAspect = true;
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
int capturedIndex = i;
|
||||
Button btn = characterPortraits[i].GetComponent<Button>();
|
||||
if (btn == null) btn = characterPortraits[i].gameObject.AddComponent<Button>();
|
||||
btn.transition = Selectable.Transition.None;
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => OnSlotClicked(capturedIndex));
|
||||
}
|
||||
|
||||
// Configure health dial as radial fill
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null)
|
||||
{
|
||||
Image dial = healthDialImages[i];
|
||||
if (dialSprite != null) dial.sprite = dialSprite;
|
||||
dial.type = Image.Type.Filled;
|
||||
dial.fillMethod = Image.FillMethod.Radial360;
|
||||
dial.fillOrigin = (int)Image.Origin360.Top;
|
||||
dial.fillClockwise = true;
|
||||
dial.fillAmount = 1f;
|
||||
dial.color = healthFullColor;
|
||||
}
|
||||
|
||||
// Set name label
|
||||
if (i < nameLabels.Count && nameLabels[i] != null && character != null)
|
||||
{
|
||||
nameLabels[i].text = GetCharacterName(character);
|
||||
}
|
||||
|
||||
// Hide dead overlay
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null)
|
||||
{
|
||||
deadOverlays[i].SetActive(false);
|
||||
}
|
||||
|
||||
// Default border
|
||||
if (i < highlightBorders.Count && highlightBorders[i] != null)
|
||||
{
|
||||
highlightBorders[i].color = defaultBorderColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide any extra slots beyond team size
|
||||
for (int i = count; i < characterPortraits.Count; i++)
|
||||
{
|
||||
if (characterPortraits[i] != null) characterPortraits[i].gameObject.SetActive(false);
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null) healthDialImages[i].gameObject.SetActive(false);
|
||||
if (i < dialBackgrounds.Count && dialBackgrounds[i] != null) dialBackgrounds[i].gameObject.SetActive(false);
|
||||
if (i < nameLabels.Count && nameLabels[i] != null) nameLabels[i].gameObject.SetActive(false);
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null) deadOverlays[i].SetActive(false);
|
||||
if (i < highlightBorders.Count && highlightBorders[i] != null) highlightBorders[i].gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
SetHighlight(currentControlledIndex);
|
||||
|
||||
// Show panel
|
||||
_canvasGroup.alpha = 1f;
|
||||
_canvasGroup.blocksRaycasts = true;
|
||||
_canvasGroup.interactable = true;
|
||||
}
|
||||
|
||||
/// <summary>Update health dials + keyboard shortcuts. Health polling throttled to ~10Hz.</summary>
|
||||
private void Update()
|
||||
{
|
||||
// Keyboard shortcuts: 1, 2, 3 to switch characters (always responsive)
|
||||
if (_characters.Count > 0 && _canvasGroup.alpha > 0f)
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Alpha1) && _characters.Count > 0) OnSlotClicked(0);
|
||||
if (Input.GetKeyDown(KeyCode.Alpha2) && _characters.Count > 1) OnSlotClicked(1);
|
||||
if (Input.GetKeyDown(KeyCode.Alpha3) && _characters.Count > 2) OnSlotClicked(2);
|
||||
}
|
||||
|
||||
// Throttle health polling to every 6th frame (~10Hz at 60fps)
|
||||
// Health bars update smoothly enough at 10Hz — indistinguishable from 60Hz
|
||||
if (Time.frameCount % 6 != 0) return;
|
||||
|
||||
for (int i = 0; i < _healthComps.Count; i++)
|
||||
{
|
||||
HealthNew h = _healthComps[i];
|
||||
if (h == null) continue;
|
||||
|
||||
// Health ring fill
|
||||
float maxHP = h.maxHealth > 0 ? h.maxHealth : 100f;
|
||||
float pct = Mathf.Clamp01((float)h.currentHealth / maxHP);
|
||||
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null)
|
||||
{
|
||||
healthDialImages[i].fillAmount = pct;
|
||||
healthDialImages[i].color = Color.Lerp(healthLowColor, healthFullColor, pct);
|
||||
}
|
||||
|
||||
// Dead overlay
|
||||
bool dead = h.isDead;
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null)
|
||||
{
|
||||
if (deadOverlays[i].activeSelf != dead)
|
||||
deadOverlays[i].SetActive(dead);
|
||||
}
|
||||
|
||||
// Disable click on dead characters
|
||||
if (i < characterPortraits.Count && characterPortraits[i] != null)
|
||||
{
|
||||
Button btn = characterPortraits[i].GetComponent<Button>();
|
||||
if (btn != null) btn.interactable = !dead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Events ──────────────────────────────────────────────
|
||||
|
||||
private void OnCharacterSwitched(GameObject oldChar, GameObject newChar)
|
||||
{
|
||||
if (newChar == null) return;
|
||||
for (int i = 0; i < _characters.Count; i++)
|
||||
{
|
||||
if (_characters[i] == newChar)
|
||||
{
|
||||
SetHighlight(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetHighlight(int index)
|
||||
{
|
||||
// Remove old highlight
|
||||
if (_currentHighlight >= 0 && _currentHighlight < highlightBorders.Count
|
||||
&& highlightBorders[_currentHighlight] != null)
|
||||
{
|
||||
highlightBorders[_currentHighlight].color = defaultBorderColor;
|
||||
}
|
||||
if (_currentHighlight >= 0 && _currentHighlight < characterPortraits.Count
|
||||
&& characterPortraits[_currentHighlight] != null)
|
||||
{
|
||||
characterPortraits[_currentHighlight].transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
_currentHighlight = index;
|
||||
|
||||
// Apply new highlight
|
||||
if (index >= 0 && index < highlightBorders.Count && highlightBorders[index] != null)
|
||||
{
|
||||
highlightBorders[index].color = highlightColor;
|
||||
}
|
||||
if (index >= 0 && index < characterPortraits.Count && characterPortraits[index] != null)
|
||||
{
|
||||
characterPortraits[index].transform.localScale = Vector3.one * 1.1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSlotClicked(int index)
|
||||
{
|
||||
if (index < 0 || index >= _characters.Count) return;
|
||||
if (index < _healthComps.Count && _healthComps[index] != null && _healthComps[index].isDead) return;
|
||||
if (index == _currentHighlight) return;
|
||||
|
||||
// Play switch SFX
|
||||
if (switchSFX != null && _audio != null)
|
||||
_audio.PlayOneShot(switchSFX, sfxVolume);
|
||||
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr != null)
|
||||
{
|
||||
mgr.SwitchToCharacterAtIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void HideImmediate()
|
||||
{
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────
|
||||
|
||||
private Sprite GetCharacterIcon(GameObject character)
|
||||
{
|
||||
if (character == null) return null;
|
||||
|
||||
var ai = character.GetComponent<CharacterAIController>();
|
||||
if (ai != null)
|
||||
{
|
||||
var field = typeof(CharacterAIController)
|
||||
.GetField("characterStats",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
if (field != null)
|
||||
{
|
||||
CharacterStats stats = field.GetValue(ai) as CharacterStats;
|
||||
if (stats != null)
|
||||
{
|
||||
if (stats.characterIcon != null) return stats.characterIcon;
|
||||
if (stats.characterPortrait != null) return stats.characterPortrait;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string cleanName = character.name.Replace("(Clone)", "").Trim();
|
||||
return Resources.Load<Sprite>($"CharacterPortraits/{cleanName}");
|
||||
}
|
||||
|
||||
private string GetCharacterName(GameObject character)
|
||||
{
|
||||
if (character == null) return "?";
|
||||
|
||||
var ai = character.GetComponent<CharacterAIController>();
|
||||
if (ai != null)
|
||||
{
|
||||
var field = typeof(CharacterAIController)
|
||||
.GetField("characterStats",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
if (field != null)
|
||||
{
|
||||
CharacterStats stats = field.GetValue(ai) as CharacterStats;
|
||||
if (stats != null && !string.IsNullOrEmpty(stats.displayName))
|
||||
return stats.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
return character.name.Replace("(Clone)", "").Trim();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs.meta
Normal file
2
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56699a52ec9e6fe46aaae72918a28303
|
||||
363
Assets/Scripts/CashSystem/VaultSpawner.cs
Normal file
363
Assets/Scripts/CashSystem/VaultSpawner.cs
Normal file
@@ -0,0 +1,363 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// VaultSpawner — Spawns ExtractionVault objectives at arena points.
|
||||
///
|
||||
/// Simpler than CashBagSpawner: spawns 1-2 vaults at a time at designated
|
||||
/// spawn points spread across the map. When a vault is deposited or despawns,
|
||||
/// a new one spawns at a different location after a delay.
|
||||
///
|
||||
/// Uses the same patterns as CashBagSpawner: NavMesh snap, EntityRegistry,
|
||||
/// GameEvents, ObjectPool.
|
||||
/// </summary>
|
||||
public class VaultSpawner : MonoBehaviour
|
||||
{
|
||||
[Header("Vault Settings")]
|
||||
[SerializeField] private GameObject vaultPrefab;
|
||||
[SerializeField] private float vaultValue = 10000f;
|
||||
|
||||
[Header("Spawn Limits")]
|
||||
[SerializeField] private int maxActiveVaults = 2;
|
||||
[SerializeField] private int initialSpawnCount = 1;
|
||||
[SerializeField] private float respawnDelay = 15f; // Seconds before a new vault spawns after one is consumed
|
||||
|
||||
[Header("Spawn Points")]
|
||||
[SerializeField] private Transform[] spawnPoints;
|
||||
[SerializeField] private string spawnPointTag = "VaultSpawn"; // Fallback tag to find spawn points
|
||||
[SerializeField] private float minDistanceBetweenVaults = 30f;
|
||||
[SerializeField] private float playerAvoidDistance = 10f;
|
||||
[SerializeField] private float stationAvoidDistance = 15f; // Don't spawn too close to cashout stations
|
||||
|
||||
[Header("Random Spawning (Fallback)")]
|
||||
[SerializeField] private bool useRandomFallback = true;
|
||||
[SerializeField] private Vector3 spawnAreaCenter = Vector3.zero;
|
||||
[SerializeField] private Vector3 spawnAreaSize = new Vector3(80f, 0f, 80f);
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool showGizmos = true;
|
||||
|
||||
// ─── Runtime ─────────────────────────────────────────────
|
||||
private readonly List<ExtractionVault> _activeVaults = new List<ExtractionVault>(4);
|
||||
private readonly HashSet<int> _usedSpawnPoints = new HashSet<int>();
|
||||
private readonly Dictionary<int, float> _spawnPointCooldowns = new Dictionary<int, float>();
|
||||
private const float SPAWN_POINT_COOLDOWN = 30f;
|
||||
private bool _isSpawning;
|
||||
|
||||
public int ActiveVaultCount => _activeVaults.Count;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find spawn points by tag if none assigned
|
||||
if (spawnPoints == null || spawnPoints.Length == 0)
|
||||
{
|
||||
GameObject[] objs = GameObject.FindGameObjectsWithTag(spawnPointTag);
|
||||
if (objs.Length > 0)
|
||||
{
|
||||
spawnPoints = new Transform[objs.Length];
|
||||
for (int i = 0; i < objs.Length; i++)
|
||||
spawnPoints[i] = objs[i].transform;
|
||||
DevLog.Log($"[VaultSpawner] Found {objs.Length} spawn points by tag '{spawnPointTag}'");
|
||||
}
|
||||
else if (!useRandomFallback)
|
||||
{
|
||||
DevLog.LogWarning("[VaultSpawner] No spawn points found and random fallback disabled!");
|
||||
}
|
||||
}
|
||||
|
||||
StartSpawning();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnCashoutCompleted += OnVaultCashedOut;
|
||||
GameEvents.OnCashoutHacked += OnVaultCashedOut;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnCashoutCompleted -= OnVaultCashedOut;
|
||||
GameEvents.OnCashoutHacked -= OnVaultCashedOut;
|
||||
}
|
||||
|
||||
// ─── Spawning ────────────────────────────────────────────
|
||||
|
||||
public void StartSpawning()
|
||||
{
|
||||
if (_isSpawning) return;
|
||||
_isSpawning = true;
|
||||
|
||||
for (int i = 0; i < initialSpawnCount; i++)
|
||||
SpawnVault();
|
||||
|
||||
GameEvents.FirePopupMessage("VAULTS DETECTED!", 2f);
|
||||
}
|
||||
|
||||
public void StopSpawning()
|
||||
{
|
||||
_isSpawning = false;
|
||||
}
|
||||
|
||||
private void SpawnVault()
|
||||
{
|
||||
if (vaultPrefab == null)
|
||||
{
|
||||
DevLog.LogWarning("[VaultSpawner] No vault prefab assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 spawnPos = GetValidSpawnPosition();
|
||||
if (spawnPos == Vector3.zero)
|
||||
{
|
||||
DevLog.LogWarning("[VaultSpawner] Could not find valid spawn position");
|
||||
return;
|
||||
}
|
||||
|
||||
// Snap to NavMesh
|
||||
NavMeshHit hit;
|
||||
if (NavMesh.SamplePosition(spawnPos, out hit, 10f, NavMesh.AllAreas))
|
||||
spawnPos = hit.position + Vector3.up * 0.7f;
|
||||
|
||||
GameObject vaultObj = Instantiate(vaultPrefab, spawnPos, Quaternion.identity);
|
||||
ExtractionVault vault = vaultObj.GetComponent<ExtractionVault>();
|
||||
if (vault == null)
|
||||
vault = vaultObj.AddComponent<ExtractionVault>();
|
||||
|
||||
_activeVaults.Add(vault);
|
||||
|
||||
// Add minimap marker
|
||||
if (vaultObj.GetComponent<MinimapObjectMarker>() == null)
|
||||
vaultObj.AddComponent<MinimapObjectMarker>();
|
||||
|
||||
GameEvents.FireExtractionVaultSpawned(vault);
|
||||
GameEvents.FireKillFeedEntry("", "NEW VAULT SPAWNED", "");
|
||||
|
||||
DevLog.Log($"[VaultSpawner] Vault spawned at {spawnPos}");
|
||||
}
|
||||
|
||||
// ─── Position Validation ─────────────────────────────────
|
||||
|
||||
private Vector3 GetValidSpawnPosition()
|
||||
{
|
||||
if (spawnPoints != null && spawnPoints.Length > 0)
|
||||
return GetSpawnPointPosition();
|
||||
|
||||
if (useRandomFallback)
|
||||
return GetRandomSpawnPosition();
|
||||
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
private Vector3 GetSpawnPointPosition()
|
||||
{
|
||||
// Clean expired cooldowns
|
||||
List<int> expired = null;
|
||||
foreach (var kvp in _spawnPointCooldowns)
|
||||
{
|
||||
if (Time.time >= kvp.Value)
|
||||
{
|
||||
if (expired == null) expired = new List<int>(4);
|
||||
expired.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
if (expired != null)
|
||||
foreach (int idx in expired)
|
||||
_spawnPointCooldowns.Remove(idx);
|
||||
|
||||
// Shuffle indices
|
||||
List<int> indices = new List<int>(spawnPoints.Length);
|
||||
for (int i = 0; i < spawnPoints.Length; i++)
|
||||
indices.Add(i);
|
||||
for (int i = indices.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Range(0, i + 1);
|
||||
(indices[i], indices[j]) = (indices[j], indices[i]);
|
||||
}
|
||||
|
||||
// Pick first valid, non-cooldown point
|
||||
foreach (int idx in indices)
|
||||
{
|
||||
if (spawnPoints[idx] == null) continue;
|
||||
if (_usedSpawnPoints.Contains(idx)) continue;
|
||||
if (_spawnPointCooldowns.ContainsKey(idx)) continue;
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_usedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: allow cooldown points
|
||||
foreach (int idx in indices)
|
||||
{
|
||||
if (spawnPoints[idx] == null) continue;
|
||||
if (_usedSpawnPoints.Contains(idx)) continue;
|
||||
if (IsPositionValid(spawnPoints[idx].position))
|
||||
{
|
||||
_usedSpawnPoints.Add(idx);
|
||||
return spawnPoints[idx].position;
|
||||
}
|
||||
}
|
||||
|
||||
return useRandomFallback ? GetRandomSpawnPosition() : Vector3.zero;
|
||||
}
|
||||
|
||||
private Vector3 GetRandomSpawnPosition()
|
||||
{
|
||||
for (int attempt = 0; attempt < 30; attempt++)
|
||||
{
|
||||
Vector3 pos = spawnAreaCenter + new Vector3(
|
||||
Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2),
|
||||
0.5f,
|
||||
Random.Range(-spawnAreaSize.z / 2, spawnAreaSize.z / 2)
|
||||
);
|
||||
|
||||
if (IsPositionValid(pos))
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Last resort
|
||||
return spawnAreaCenter + new Vector3(Random.Range(-10f, 10f), 0.5f, Random.Range(-10f, 10f));
|
||||
}
|
||||
|
||||
private bool IsPositionValid(Vector3 pos)
|
||||
{
|
||||
// Check distance from active vaults
|
||||
for (int i = 0; i < _activeVaults.Count; i++)
|
||||
{
|
||||
if (_activeVaults[i] != null && Vector3.Distance(pos, _activeVaults[i].transform.position) < minDistanceBetweenVaults)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance from characters
|
||||
foreach (TeamMember member in TeamMember.AllMembers)
|
||||
{
|
||||
if (member != null && Vector3.Distance(pos, member.transform.position) < playerAvoidDistance)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance from cashout stations
|
||||
var stations = EntityRegistry<CashoutStation>.GetAll();
|
||||
for (int i = 0; i < stations.Count; i++)
|
||||
{
|
||||
if (stations[i] != null && Vector3.Distance(pos, stations[i].transform.position) < stationAvoidDistance)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Event Handlers ──────────────────────────────────────
|
||||
|
||||
/// <summary>Called when a vault's cashout completes or gets hacked — spawn replacement.</summary>
|
||||
private void OnVaultCashedOut(CashoutStation station, string teamTag, float amount)
|
||||
{
|
||||
// Clean up null/inactive vaults
|
||||
_activeVaults.RemoveAll(v => v == null || !v.gameObject.activeInHierarchy);
|
||||
|
||||
if (_isSpawning && _activeVaults.Count < maxActiveVaults)
|
||||
{
|
||||
StartCoroutine(DelayedRespawn());
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator DelayedRespawn()
|
||||
{
|
||||
yield return new WaitForSeconds(respawnDelay);
|
||||
if (!_isSpawning) yield break;
|
||||
|
||||
_activeVaults.RemoveAll(v => v == null || !v.gameObject.activeInHierarchy);
|
||||
|
||||
if (_activeVaults.Count < maxActiveVaults)
|
||||
{
|
||||
SpawnVault();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Periodically check for despawned vaults and replace them.</summary>
|
||||
private void Update()
|
||||
{
|
||||
// Clean up null/inactive vaults every few seconds
|
||||
if (Time.frameCount % 180 == 0)
|
||||
{
|
||||
int before = _activeVaults.Count;
|
||||
_activeVaults.RemoveAll(v => v == null || !v.gameObject.activeInHierarchy);
|
||||
int removed = before - _activeVaults.Count;
|
||||
|
||||
if (removed > 0 && _isSpawning && _activeVaults.Count < maxActiveVaults)
|
||||
{
|
||||
StartCoroutine(DelayedRespawn());
|
||||
}
|
||||
|
||||
// Also release spawn points for inactive vaults
|
||||
ReleaseInactiveSpawnPoints();
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseInactiveSpawnPoints()
|
||||
{
|
||||
if (spawnPoints == null) return;
|
||||
|
||||
List<int> toRelease = null;
|
||||
foreach (int idx in _usedSpawnPoints)
|
||||
{
|
||||
if (idx < 0 || idx >= spawnPoints.Length || spawnPoints[idx] == null) continue;
|
||||
|
||||
bool stillOccupied = false;
|
||||
for (int i = 0; i < _activeVaults.Count; i++)
|
||||
{
|
||||
if (_activeVaults[i] != null &&
|
||||
Vector3.Distance(_activeVaults[i].SpawnPosition, spawnPoints[idx].position) < 5f)
|
||||
{
|
||||
stillOccupied = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stillOccupied)
|
||||
{
|
||||
if (toRelease == null) toRelease = new List<int>(4);
|
||||
toRelease.Add(idx);
|
||||
}
|
||||
}
|
||||
|
||||
if (toRelease != null)
|
||||
{
|
||||
foreach (int idx in toRelease)
|
||||
{
|
||||
_usedSpawnPoints.Remove(idx);
|
||||
_spawnPointCooldowns[idx] = Time.time + SPAWN_POINT_COOLDOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Gizmos ──────────────────────────────────────────────
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
Gizmos.color = new Color(1f, 0.84f, 0f, 0.2f);
|
||||
Gizmos.DrawCube(spawnAreaCenter, spawnAreaSize);
|
||||
Gizmos.color = new Color(1f, 0.84f, 0f, 0.5f);
|
||||
Gizmos.DrawWireCube(spawnAreaCenter, spawnAreaSize);
|
||||
|
||||
if (spawnPoints != null)
|
||||
{
|
||||
Gizmos.color = new Color(1f, 0.84f, 0f, 0.8f);
|
||||
foreach (Transform point in spawnPoints)
|
||||
if (point != null)
|
||||
Gizmos.DrawSphere(point.position, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/CashSystem/VaultSpawner.cs.meta
Normal file
2
Assets/Scripts/CashSystem/VaultSpawner.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e31035fb2126f45f4a2f015f41df60ae
|
||||
Reference in New Issue
Block a user