369 lines
14 KiB
C#
369 lines
14 KiB
C#
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();
|
|
}
|
|
}
|