chunk 1: core gameplay scripts scenes runtime assets
This commit is contained in:
368
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs
Normal file
368
Assets/Scripts/CashSystem/UI/TeamHUDPanel.cs
Normal file
@@ -0,0 +1,368 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// TeamHUDPanel — FRAG-style circular portrait HUD showing team health and character switching.
|
||||
///
|
||||
/// SETUP (Inspector):
|
||||
/// 1. Create your HUD layout manually — place circular Image slots wherever you want.
|
||||
/// 2. Assign them into the lists below:
|
||||
/// - characterPortraits[] — Image where the character icon/portrait sprite will go
|
||||
/// - healthDialImages[] — Image used as a radial-fill health ring around each portrait
|
||||
/// - dialBackgrounds[] — (optional) background ring images behind the health dials
|
||||
/// - nameLabels[] — (optional) TMP text for character names
|
||||
/// 3. The script fills sprites and updates health dials every frame.
|
||||
/// 4. Click/tap a portrait to switch control to that character.
|
||||
///
|
||||
/// Uses CanvasGroup to hide initially — stays alive for Initialize() call.
|
||||
/// </summary>
|
||||
public class TeamHUDPanel : MonoBehaviour
|
||||
{
|
||||
[Header("Character Portraits (assign in order: index 0 = team slot 0, etc.)")]
|
||||
[Tooltip("Image components where character icon sprites will be placed")]
|
||||
[SerializeField] private List<Image> characterPortraits = new List<Image>();
|
||||
|
||||
[Header("Health Dial Rings (same index order as portraits)")]
|
||||
[Tooltip("Image components used as radial-fill health rings — set to Filled/Radial360 at runtime")]
|
||||
[SerializeField] private List<Image> healthDialImages = new List<Image>();
|
||||
|
||||
[Header("Dial Backgrounds (optional, same index order)")]
|
||||
[Tooltip("Background ring images behind each health dial")]
|
||||
[SerializeField] private List<Image> dialBackgrounds = new List<Image>();
|
||||
|
||||
[Header("Name Labels (optional, same index order)")]
|
||||
[SerializeField] private List<TextMeshProUGUI> nameLabels = new List<TextMeshProUGUI>();
|
||||
|
||||
[Header("Dead Overlay (optional, same index order)")]
|
||||
[Tooltip("GameObjects to show when a character is dead (e.g. a dark overlay with X)")]
|
||||
[SerializeField] private List<GameObject> deadOverlays = new List<GameObject>();
|
||||
|
||||
[Header("Highlight Border (optional, same index order)")]
|
||||
[Tooltip("Image used as a highlight border — only visible on the active character")]
|
||||
[SerializeField] private List<Image> highlightBorders = new List<Image>();
|
||||
|
||||
[Header("Dial Sprite")]
|
||||
[Tooltip("Sprite for the health dial ring. If empty, tries to copy from ImportUIPanel's ProgressDial.")]
|
||||
[SerializeField] private Sprite dialSprite;
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private AudioClip switchSFX;
|
||||
[SerializeField] private AudioClip buttonHoverSFX;
|
||||
[SerializeField] private float sfxVolume = 1f;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color healthFullColor = new Color(0.30f, 0.87f, 0.30f, 1f);
|
||||
[SerializeField] private Color healthLowColor = new Color(0.96f, 0.26f, 0.21f, 1f);
|
||||
[SerializeField] private Color highlightColor = new Color(1f, 0.84f, 0f, 1f);
|
||||
[SerializeField] private Color defaultBorderColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
|
||||
|
||||
// runtime
|
||||
private List<GameObject> _characters = new List<GameObject>();
|
||||
private List<HealthNew> _healthComps = new List<HealthNew>();
|
||||
private int _currentHighlight = -1;
|
||||
private CanvasGroup _canvasGroup;
|
||||
private AudioSource _audio;
|
||||
|
||||
private static TeamHUDPanel _instance;
|
||||
public static TeamHUDPanel Instance => _instance;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_instance = this;
|
||||
|
||||
_canvasGroup = GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) _canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
_audio = GetComponent<AudioSource>();
|
||||
if (_audio == null) _audio = gameObject.AddComponent<AudioSource>();
|
||||
_audio.playOnAwake = false;
|
||||
_audio.spatialBlend = 0f;
|
||||
|
||||
// Hide via CanvasGroup until Initialize is called
|
||||
HideImmediate();
|
||||
|
||||
// Try to find dial sprite from ImportUIPanel if not assigned
|
||||
if (dialSprite == null)
|
||||
{
|
||||
var importPanel = GameObject.Find("ImportUIPanel");
|
||||
if (importPanel != null)
|
||||
{
|
||||
var dial = importPanel.transform.Find("ProgressDial");
|
||||
if (dial != null)
|
||||
{
|
||||
var img = dial.GetComponent<Image>();
|
||||
if (img != null) dialSprite = img.sprite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
GameEvents.OnCharacterSwitched += OnCharacterSwitched;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
GameEvents.OnCharacterSwitched -= OnCharacterSwitched;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the HUD with the player team characters.
|
||||
/// Call after characters are spawned (from CashSystemManager.SpawnTeams).
|
||||
/// </summary>
|
||||
public void Initialize(List<GameObject> playerTeamCharacters, int currentControlledIndex)
|
||||
{
|
||||
DevLog.Log($"[TeamHUDPanel] Initialize with {playerTeamCharacters?.Count ?? 0} characters, active={currentControlledIndex}");
|
||||
|
||||
_characters.Clear();
|
||||
_healthComps.Clear();
|
||||
|
||||
if (playerTeamCharacters == null || playerTeamCharacters.Count == 0) return;
|
||||
|
||||
int count = Mathf.Min(playerTeamCharacters.Count, characterPortraits.Count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
GameObject character = playerTeamCharacters[i];
|
||||
_characters.Add(character);
|
||||
_healthComps.Add(character != null ? character.GetComponent<HealthNew>() : null);
|
||||
|
||||
// Set portrait sprite
|
||||
if (i < characterPortraits.Count && characterPortraits[i] != null && character != null)
|
||||
{
|
||||
Sprite icon = GetCharacterIcon(character);
|
||||
if (icon != null)
|
||||
{
|
||||
characterPortraits[i].sprite = icon;
|
||||
characterPortraits[i].color = Color.white;
|
||||
characterPortraits[i].preserveAspect = true;
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
int capturedIndex = i;
|
||||
Button btn = characterPortraits[i].GetComponent<Button>();
|
||||
if (btn == null) btn = characterPortraits[i].gameObject.AddComponent<Button>();
|
||||
btn.transition = Selectable.Transition.None;
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => OnSlotClicked(capturedIndex));
|
||||
}
|
||||
|
||||
// Configure health dial as radial fill
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null)
|
||||
{
|
||||
Image dial = healthDialImages[i];
|
||||
if (dialSprite != null) dial.sprite = dialSprite;
|
||||
dial.type = Image.Type.Filled;
|
||||
dial.fillMethod = Image.FillMethod.Radial360;
|
||||
dial.fillOrigin = (int)Image.Origin360.Top;
|
||||
dial.fillClockwise = true;
|
||||
dial.fillAmount = 1f;
|
||||
dial.color = healthFullColor;
|
||||
}
|
||||
|
||||
// Set name label
|
||||
if (i < nameLabels.Count && nameLabels[i] != null && character != null)
|
||||
{
|
||||
nameLabels[i].text = GetCharacterName(character);
|
||||
}
|
||||
|
||||
// Hide dead overlay
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null)
|
||||
{
|
||||
deadOverlays[i].SetActive(false);
|
||||
}
|
||||
|
||||
// Default border
|
||||
if (i < highlightBorders.Count && highlightBorders[i] != null)
|
||||
{
|
||||
highlightBorders[i].color = defaultBorderColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide any extra slots beyond team size
|
||||
for (int i = count; i < characterPortraits.Count; i++)
|
||||
{
|
||||
if (characterPortraits[i] != null) characterPortraits[i].gameObject.SetActive(false);
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null) healthDialImages[i].gameObject.SetActive(false);
|
||||
if (i < dialBackgrounds.Count && dialBackgrounds[i] != null) dialBackgrounds[i].gameObject.SetActive(false);
|
||||
if (i < nameLabels.Count && nameLabels[i] != null) nameLabels[i].gameObject.SetActive(false);
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null) deadOverlays[i].SetActive(false);
|
||||
if (i < highlightBorders.Count && highlightBorders[i] != null) highlightBorders[i].gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
SetHighlight(currentControlledIndex);
|
||||
|
||||
// Show panel
|
||||
_canvasGroup.alpha = 1f;
|
||||
_canvasGroup.blocksRaycasts = true;
|
||||
_canvasGroup.interactable = true;
|
||||
}
|
||||
|
||||
/// <summary>Update health dials + keyboard shortcuts. Health polling throttled to ~10Hz.</summary>
|
||||
private void Update()
|
||||
{
|
||||
// Keyboard shortcuts: 1, 2, 3 to switch characters (always responsive)
|
||||
if (_characters.Count > 0 && _canvasGroup.alpha > 0f)
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Alpha1) && _characters.Count > 0) OnSlotClicked(0);
|
||||
if (Input.GetKeyDown(KeyCode.Alpha2) && _characters.Count > 1) OnSlotClicked(1);
|
||||
if (Input.GetKeyDown(KeyCode.Alpha3) && _characters.Count > 2) OnSlotClicked(2);
|
||||
}
|
||||
|
||||
// Throttle health polling to every 6th frame (~10Hz at 60fps)
|
||||
// Health bars update smoothly enough at 10Hz — indistinguishable from 60Hz
|
||||
if (Time.frameCount % 6 != 0) return;
|
||||
|
||||
for (int i = 0; i < _healthComps.Count; i++)
|
||||
{
|
||||
HealthNew h = _healthComps[i];
|
||||
if (h == null) continue;
|
||||
|
||||
// Health ring fill
|
||||
float maxHP = h.maxHealth > 0 ? h.maxHealth : 100f;
|
||||
float pct = Mathf.Clamp01((float)h.currentHealth / maxHP);
|
||||
|
||||
if (i < healthDialImages.Count && healthDialImages[i] != null)
|
||||
{
|
||||
healthDialImages[i].fillAmount = pct;
|
||||
healthDialImages[i].color = Color.Lerp(healthLowColor, healthFullColor, pct);
|
||||
}
|
||||
|
||||
// Dead overlay
|
||||
bool dead = h.isDead;
|
||||
if (i < deadOverlays.Count && deadOverlays[i] != null)
|
||||
{
|
||||
if (deadOverlays[i].activeSelf != dead)
|
||||
deadOverlays[i].SetActive(dead);
|
||||
}
|
||||
|
||||
// Disable click on dead characters
|
||||
if (i < characterPortraits.Count && characterPortraits[i] != null)
|
||||
{
|
||||
Button btn = characterPortraits[i].GetComponent<Button>();
|
||||
if (btn != null) btn.interactable = !dead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Events ──────────────────────────────────────────────
|
||||
|
||||
private void OnCharacterSwitched(GameObject oldChar, GameObject newChar)
|
||||
{
|
||||
if (newChar == null) return;
|
||||
for (int i = 0; i < _characters.Count; i++)
|
||||
{
|
||||
if (_characters[i] == newChar)
|
||||
{
|
||||
SetHighlight(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetHighlight(int index)
|
||||
{
|
||||
// Remove old highlight
|
||||
if (_currentHighlight >= 0 && _currentHighlight < highlightBorders.Count
|
||||
&& highlightBorders[_currentHighlight] != null)
|
||||
{
|
||||
highlightBorders[_currentHighlight].color = defaultBorderColor;
|
||||
}
|
||||
if (_currentHighlight >= 0 && _currentHighlight < characterPortraits.Count
|
||||
&& characterPortraits[_currentHighlight] != null)
|
||||
{
|
||||
characterPortraits[_currentHighlight].transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
_currentHighlight = index;
|
||||
|
||||
// Apply new highlight
|
||||
if (index >= 0 && index < highlightBorders.Count && highlightBorders[index] != null)
|
||||
{
|
||||
highlightBorders[index].color = highlightColor;
|
||||
}
|
||||
if (index >= 0 && index < characterPortraits.Count && characterPortraits[index] != null)
|
||||
{
|
||||
characterPortraits[index].transform.localScale = Vector3.one * 1.1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSlotClicked(int index)
|
||||
{
|
||||
if (index < 0 || index >= _characters.Count) return;
|
||||
if (index < _healthComps.Count && _healthComps[index] != null && _healthComps[index].isDead) return;
|
||||
if (index == _currentHighlight) return;
|
||||
|
||||
// Play switch SFX
|
||||
if (switchSFX != null && _audio != null)
|
||||
_audio.PlayOneShot(switchSFX, sfxVolume);
|
||||
|
||||
CashSystemManager mgr = CashSystemManager.Instance;
|
||||
if (mgr != null)
|
||||
{
|
||||
mgr.SwitchToCharacterAtIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void HideImmediate()
|
||||
{
|
||||
_canvasGroup.alpha = 0f;
|
||||
_canvasGroup.blocksRaycasts = false;
|
||||
_canvasGroup.interactable = false;
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────
|
||||
|
||||
private Sprite GetCharacterIcon(GameObject character)
|
||||
{
|
||||
if (character == null) return null;
|
||||
|
||||
var ai = character.GetComponent<CharacterAIController>();
|
||||
if (ai != null)
|
||||
{
|
||||
var field = typeof(CharacterAIController)
|
||||
.GetField("characterStats",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
if (field != null)
|
||||
{
|
||||
CharacterStats stats = field.GetValue(ai) as CharacterStats;
|
||||
if (stats != null)
|
||||
{
|
||||
if (stats.characterIcon != null) return stats.characterIcon;
|
||||
if (stats.characterPortrait != null) return stats.characterPortrait;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string cleanName = character.name.Replace("(Clone)", "").Trim();
|
||||
return Resources.Load<Sprite>($"CharacterPortraits/{cleanName}");
|
||||
}
|
||||
|
||||
private string GetCharacterName(GameObject character)
|
||||
{
|
||||
if (character == null) return "?";
|
||||
|
||||
var ai = character.GetComponent<CharacterAIController>();
|
||||
if (ai != null)
|
||||
{
|
||||
var field = typeof(CharacterAIController)
|
||||
.GetField("characterStats",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
if (field != null)
|
||||
{
|
||||
CharacterStats stats = field.GetValue(ai) as CharacterStats;
|
||||
if (stats != null && !string.IsNullOrEmpty(stats.displayName))
|
||||
return stats.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
return character.name.Replace("(Clone)", "").Trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user