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,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