chunk 1: core gameplay scripts scenes runtime assets

This commit is contained in:
2026-04-06 11:02:34 +03:00
parent fa0388bc79
commit 0d11a097d8
703 changed files with 2292651 additions and 0 deletions

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ba5b44417d2bdcb898326c6c046307c

View 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);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a1fa35c76b8d04cfb343b59d172b2ab

View 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");
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2bc8982e2d3d6af29a9b51c9542690bd

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cf4ee3cf212672b8d96990e29bcb472c

File diff suppressed because it is too large Load Diff

View 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;
}
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5aec122c92ee9438f9f63d8380f95a54
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f09b669c3c561e97c9daa3b163b4ccc7

View 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
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6735d1bcb03a417a3b7740abefe5c85e

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 16b2e908cc98322f99c8bbc3305e0322

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70704835259870dbfa30e3d56bde388f

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f7f2c6d188d56a044864ff5245a921c4

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e73597034e201424e81a35d9a484b454

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e917c4b8cb4308a08bf6c07a278c5004

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 813c3ca1efd3145d9a0a04773b778ea9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5fcb73e900cc542498962703b55def9e

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d965cc3b37bf114f1a94af15fdcd2151

View 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();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 124c27138f4bba3059796c7a104e7b04

View 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();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 56699a52ec9e6fe46aaae72918a28303

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e31035fb2126f45f4a2f015f41df60ae