using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; [ExecuteAlways] /// /// AllyCombatant 绑定到场上友军的运行时 GameObject(命名为 ally_01..ally_05), /// 实现 ICombatant 接口以供 EffectSystem 调用。 /// - 从 teamUIController 获取 UI 引用并同步显示血量/法力 /// - 启动时从 TeamCharacterDataInfo 或 AllyHero_SO 填充最大生命/法力等基础属性 /// - 初始生命值为最大生命值,初始法力为 0 /// public class AllyCombatant : MonoBehaviour, ICombatant { public int slotIndex = 0; // 0..4 对应 ally_01..ally_05 // runtime stats public int maxHP = 100; public int currentHP = 100; public int maxMana = 100; public int currentMana = 0; public float damageResistance = 0f; // 0..1 public float scoreEfficiency = 1f; private bool isDead = false; // new: dead flag // add attack stat to be available at runtime [Tooltip("基础攻击力(运行时使用),会从 AllyHero_SO.levelStats[0].attack 填充(如果存在)")] public int attack = 0; // scoring fields [Header("Scoring")] [Tooltip("Base score value for a perfect hit on this track")] public int baseTrackScore = 1000; [Tooltip("Score multiplier for Great (e.g. 0.75)")] public float greatRatio = 0.75f; [Tooltip("Score multiplier for Good (e.g. 0.5)")] public float goodRatio = 0.5f; [Tooltip("Score multiplier for Miss (e.g. 0)")] public float missRatio = 0f; [Tooltip("角色当前轨道得分(累加),显示于 UI 的 currentScore 文本)")] public int currentScore = 0; [Tooltip("轨道分数上限,暂设为 int.MaxValue - 1")] public int maxTrackScore = int.MaxValue - 1; // When true, InitializeStatsFromData() will overwrite inspector values on Start. // Set to false if you want to keep manual inspector values when entering Play mode. public bool allowOverwriteFromSO = true; // UI refs (resolved from teamUIController) private Image characterImage; private Image healthImage; private Image fadeHealthImage; private Image manaImage; private Image fadeManaImage; private TextMeshProUGUI nameText; private TextMeshProUGUI healthRateText; private TextMeshProUGUI currentScoreText; private Image hurtRedImage; private Coroutine fadeHealthCoroutine; private Coroutine fadeManaCoroutine; private List activeBuffs = new List(); private void Awake() { // ensure GameObject name matches pattern so EffectSystem can find it gameObject.name = $"ally_0{Mathf.Clamp(slotIndex + 1, 1, 5)}"; } private void Start() { // Resolve UI and optionally pull data from SO ResolveUIReferences(); if (allowOverwriteFromSO) InitializeStatsFromData(); // Ensure runtime invariants currentHP = Mathf.Clamp(currentHP, 0, Mathf.Max(1, maxHP)); currentMana = Mathf.Clamp(currentMana, 0, Mathf.Max(1, maxMana)); UpdateUIImmediate(); // Ensure score UI shows initial value UpdateScoreUI(); } // Allow editing in inspector to immediately reflect on UI (editor only) private void OnValidate() { // Avoid heavy editor-only operations, but resolve UI references so inspector changes show ResolveUIReferences(); // Clamp values to valid ranges if (maxHP < 1) maxHP = 1; if (maxMana < 1) maxMana = 1; currentHP = Mathf.Clamp(currentHP, 0, maxHP); currentMana = Mathf.Clamp(currentMana, 0, maxMana); // Do not start coroutines from OnValidate - update immediately UpdateUIImmediate(); // If user adjusts currentMana in inspector while in Play mode, attempt to trigger mana-full behaviors // Guard to avoid calling TryCastOnFullMana before singletons are initialized if (Application.isPlaying && SkillBuilder.Instance != null) { TryCastOnFullMana(); } } private void ResolveUIReferences() { // Try singleton first, fallback to scene search (works in editor) var ui = teamUIController.Instance ?? FindObjectOfType(); if (ui == null) return; switch (slotIndex) { case 0: characterImage = ui.teammate01_characterImage; healthImage = ui.teammate01_healthImage; fadeHealthImage = ui.teammate01_fadehealthImage; manaImage = ui.teammate01_manaImage; fadeManaImage = ui.teammate01_fadeManaImage; nameText = ui.teammate01_nameText; healthRateText = ui.teammate01_healthRate; currentScoreText = ui.teammate01_current_scoreText; hurtRedImage = ui.teammate01_hurtRedImage; break; case 1: characterImage = ui.teammate02_characterImage; healthImage = ui.teammate02_healthImage; fadeHealthImage = ui.teammate02_fadehealthImage; manaImage = ui.teammate02_manaImage; fadeManaImage = ui.teammate02_fadeManaImage; nameText = ui.teammate02_nameText; healthRateText = ui.teammate02_healthRate; currentScoreText = ui.teammate02_current_scoreText; hurtRedImage = ui.teammate02_hurtRedImage; break; case 2: characterImage = ui.teammate03_characterImage; healthImage = ui.teammate03_healthImage; fadeHealthImage = ui.teammate03_fadehealthImage; manaImage = ui.teammate03_manaImage; fadeManaImage = ui.teammate03_fadeManaImage; nameText = ui.teammate03_nameText; healthRateText = ui.teammate03_healthRate; currentScoreText = ui.teammate03_current_scoreText; hurtRedImage = ui.teammate03_hurtRedImage; break; case 3: characterImage = ui.teammate04_characterImage; healthImage = ui.teammate04_healthImage; fadeHealthImage = ui.teammate04_fadehealthImage; manaImage = ui.teammate04_manaImage; fadeManaImage = ui.teammate04_fadeManaImage; nameText = ui.teammate04_nameText; healthRateText = ui.teammate04_healthRate; currentScoreText = ui.teammate04_current_scoreText; hurtRedImage = ui.teammate04_hurtRedImage; break; case 4: characterImage = ui.teammate05_characterImage; healthImage = ui.teammate05_healthImage; fadeHealthImage = ui.teammate05_fadehealthImage; manaImage = ui.teammate05_manaImage; fadeManaImage = ui.teammate05_fadeManaImage; nameText = ui.teammate05_nameText; healthRateText = ui.teammate05_healthRate; currentScoreText = ui.teammate05_current_scoreText; hurtRedImage = ui.teammate05_hurtRedImage; break; } // If the assigned TMP field is null, attempt to resolve from the UI parent's object (teamUIController keeps objectFather references) if (currentScoreText == null) { GameObject parent = null; switch (slotIndex) { case 0: parent = ui.objectFather_ally01; break; case 1: parent = ui.objectFather_ally02; break; case 2: parent = ui.objectFather_ally03; break; case 3: parent = ui.objectFather_ally04; break; case 4: parent = ui.objectFather_ally05; break; } if (parent != null) { var tmp = parent.GetComponentInChildren(true); if (tmp != null) { currentScoreText = tmp; // also assign back to teamUIController so other systems can find it switch (slotIndex) { case 0: ui.teammate01_current_scoreText = tmp; break; case 1: ui.teammate02_current_scoreText = tmp; break; case 2: ui.teammate03_current_scoreText = tmp; break; case 3: ui.teammate04_current_scoreText = tmp; break; case 4: ui.teammate05_current_scoreText = tmp; break; } Debug.Log($"[AllyCombatant] Resolved currentScoreText for slot {slotIndex+1} from parent {parent.name} -> {tmp.gameObject.name}"); } else { // legacy UnityEngine.UI.Text fallback var legacy = parent.GetComponentInChildren(true); if (legacy != null) { var go = legacy.gameObject; var created = go.GetComponent() ?? go.AddComponent(); created.text = legacy.text; currentScoreText = created; switch (slotIndex) { case 0: ui.teammate01_current_scoreText = created; break; case 1: ui.teammate02_current_scoreText = created; break; case 2: ui.teammate03_current_scoreText = created; break; case 3: ui.teammate04_current_scoreText = created; break; case 4: ui.teammate05_current_scoreText = created; break; } Debug.LogWarning($"[AllyCombatant] Found legacy Text for slot {slotIndex+1} under {parent.name}. Added/used TMP component on {go.name} and copied text."); } } } } } private void InitializeStatsFromData() { // Try TeamCharacterDataInfo first var arr = teamUIController.Instance?.GetCurrentAllySOs(); if (arr != null && slotIndex >= 0 && slotIndex < arr.Length) { var so = arr[slotIndex]; if (so != null) { // TeamCharacterDataInfo has limited fields; use CharacterMaxHealth if present if (so.CharacterMaxHealth > 0) { maxHP = so.CharacterMaxHealth; } if (!string.IsNullOrEmpty(so.CharacterName) && nameText != null) { nameText.text = so.CharacterName; } if (characterImage != null && so.CharacterImage_gameplay != null) { characterImage.sprite = so.CharacterImage_gameplay.sprite; } } } // If more detailed info exists in AllyHero_SO, try to load by ally ID int id = -1; if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null && slotIndex < teamUIController.Instance.allySlotIds.Count) id = teamUIController.Instance.allySlotIds[slotIndex]; if (id > 0) { var all = Resources.LoadAll(""); foreach (var a in all) { if (a != null && a.ally_heroID == id) { // pick effective level based on current EXP if available if (a.levelStats != null && a.levelStats.Count > 0) { var eff = a.GetEffectiveLevelForCurrentEXP(); if (eff != null) { maxHP = eff.maxHP; maxMana = eff.maxMana; damageResistance = eff.damageResistance; scoreEfficiency = eff.scoreEfficiency; attack = eff.attack; // populate attack from SO effective level } else { var lvl = a.levelStats[0]; maxHP = lvl.maxHP; maxMana = lvl.maxMana; damageResistance = lvl.damageResistance; scoreEfficiency = lvl.scoreEfficiency; attack = lvl.attack; } } if (nameText != null) nameText.text = a.ally_heroName; if (characterImage != null && a.ally_heroProfile != null) characterImage.sprite = a.ally_heroProfile; break; } } } } // Scoring API public void AddScoreForJudge(string judge) { int add = 0; switch (judge) { case "Perfect": add = baseTrackScore; break; case "Great": add = Mathf.FloorToInt(baseTrackScore * greatRatio); break; case "Good": add = Mathf.FloorToInt(baseTrackScore * goodRatio); break; case "Miss": add = Mathf.FloorToInt(baseTrackScore * missRatio); break; default: add = 0; break; } // apply scoreEfficiency or other modifiers here add = Mathf.FloorToInt(add * scoreEfficiency); currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore); UpdateScoreUI(); // update global total ScoreManager.Instance?.RecalculateTotal(); } // Add an arbitrary score delta directly (used by skills to modify single judge or grant/penalize score) public void AddScoreDirect(int delta) { currentScore = Mathf.Clamp(currentScore + delta, 0, maxTrackScore); UpdateScoreUI(); ScoreManager.Instance?.RecalculateTotal(); } private void UpdateScoreUI() { string value = $"{currentScore}/{maxTrackScore}"; if (currentScoreText != null) { currentScoreText.text = value; Debug.Log($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})"); return; } // As a fallback, try to write directly to teamUIController.Instance fields if available var ui = teamUIController.Instance; if (ui != null) { switch (slotIndex) { case 0: if (ui.teammate01_current_scoreText != null) { ui.teammate01_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate01_current_scoreText: {value}"); } break; case 1: if (ui.teammate02_current_scoreText != null) { ui.teammate02_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate02_current_scoreText: {value}"); } break; case 2: if (ui.teammate03_current_scoreText != null) { ui.teammate03_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate03_current_scoreText: {value}"); } break; case 3: if (ui.teammate04_current_scoreText != null) { ui.teammate04_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate04_current_scoreText: {value}"); } break; case 4: if (ui.teammate05_current_scoreText != null) { ui.teammate05_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate05_current_scoreText: {value}"); } break; } } } // Public setters that update UI and optionally animate fade bars public void SetCurrentHP(int hp, bool animateFade = true) { if (isDead) return; // dead characters cannot change HP int old = currentHP; currentHP = Mathf.Clamp(hp, 0, maxHP); if (currentHP <= 0) isDead = true; UpdateHealthVisuals(old, animateFade); } public void ModifyHP(int delta, bool animateFade = true) { if (isDead && delta > 0) { // cannot heal a dead unit return; } SetCurrentHP(currentHP + delta, animateFade); } public void SetMaxHP(int newMax, bool keepCurrentRatio = true) { if (newMax < 1) newMax = 1; float ratio = maxHP > 0 ? (float)currentHP / maxHP : 1f; maxHP = newMax; if (keepCurrentRatio) currentHP = Mathf.Clamp(Mathf.RoundToInt(ratio * maxHP), 0, maxHP); else currentHP = Mathf.Clamp(currentHP, 0, maxHP); UpdateUIImmediate(); } public void SetCurrentMana(int mana, bool animateFade = true) { if (isDead) return; // dead characters cannot change mana int old = currentMana; currentMana = Mathf.Clamp(mana, 0, maxMana); UpdateManaVisuals(old, animateFade); TryCastOnFullMana(); } public void ModifyMana(int delta, bool animateFade = true) { if (isDead && delta > 0) { // dead units cannot gain mana return; } SetCurrentMana(currentMana + delta, animateFade); } public void SetMaxMana(int newMax, bool keepCurrentRatio = true) { if (newMax < 1) newMax = 1; float ratio = maxMana > 0 ? (float)currentMana / maxMana : 1f; maxMana = newMax; if (keepCurrentRatio) currentMana = Mathf.Clamp(Mathf.RoundToInt(ratio * maxMana), 0, maxMana); else currentMana = Mathf.Clamp(currentMana, 0, maxMana); UpdateUIImmediate(); } private void UpdateHealthVisuals(int oldHP, bool animateFade) { // main bar should snap quickly to new value, while fade bar lerps towards it if (healthImage != null) { float newFill = maxHP > 0 ? (float)currentHP / maxHP : 0f; // snap main bar quickly for visual pop healthImage.fillAmount = Mathf.MoveTowards(healthImage.fillAmount, newFill, 1f); } if (healthRateText != null) healthRateText.text = $"{currentHP}/{maxHP}"; if (fadeHealthCoroutine != null) StopCoroutine(fadeHealthCoroutine); if (fadeHealthImage != null) { float target = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f; if (animateFade && Application.isPlaying) fadeHealthCoroutine = StartCoroutine(FadeHealthCoroutine(target)); else fadeHealthImage.fillAmount = target; } // if HP decreased, show hurt flash; if dead, keep hurt overlay visible if (currentHP < oldHP && hurtRedImage != null && Application.isPlaying) { StopCoroutine("HurtFlash"); StartCoroutine(HurtFlash()); } if (isDead && hurtRedImage != null) { Color c = hurtRedImage.color; c.a = 1f; hurtRedImage.color = c; // keep overlay visible when dead } } private void UpdateManaVisuals(int oldMana, bool animateFade) { // main bar snaps, fade bar lerps to new value for visual effect if (manaImage != null) { float newFill = maxMana > 0 ? (float)currentMana / maxMana : 0f; manaImage.fillAmount = Mathf.MoveTowards(manaImage.fillAmount, newFill, 1f); } if (fadeManaCoroutine != null) StopCoroutine(fadeManaCoroutine); if (fadeManaImage != null) { float target = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f; if (animateFade && Application.isPlaying) fadeManaCoroutine = StartCoroutine(FadeManaCoroutine(target)); else fadeManaImage.fillAmount = target; } } private void UpdateUIImmediate() { if (healthImage != null) { healthImage.fillAmount = maxHP > 0 ? (float)currentHP / maxHP : 0f; } if (fadeHealthImage != null) { // keep fade bar equal to main bar immediately fadeHealthImage.fillAmount = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f; } if (manaImage != null) { manaImage.fillAmount = maxMana > 0 ? (float)currentMana / maxMana : 0f; } if (fadeManaImage != null) { fadeManaImage.fillAmount = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f; } if (healthRateText != null) { healthRateText.text = $"{currentHP}/{maxHP}"; } } private IEnumerator FadeHealthCoroutine(float targetFill) { if (fadeHealthImage == null) yield break; float start = fadeHealthImage.fillAmount; float duration = 0.6f; float t = 0f; while (t < duration) { t += Time.deltaTime; fadeHealthImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration); yield return null; } fadeHealthImage.fillAmount = targetFill; } private IEnumerator FadeManaCoroutine(float targetFill) { if (fadeManaImage == null) yield break; float start = fadeManaImage.fillAmount; float duration = 0.4f; float t = 0f; while (t < duration) { t += Time.deltaTime; fadeManaImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration); yield return null; } fadeManaImage.fillAmount = targetFill; } public void ReceiveDamage(float amount, GameObject source) { float effective = amount * (1f - damageResistance); int delta = Mathf.CeilToInt(effective); ModifyHP(-delta, true); } private IEnumerator HurtFlash() { if (hurtRedImage == null) yield break; Color c = hurtRedImage.color; c.a = 1f; hurtRedImage.color = c; // when injured, remain visible a bit longer; if dead, keep it on if (isDead) { yield break; } yield return new WaitForSeconds(0.2f); float dur = 0.4f; float t = 0f; while (t < dur) { t += Time.deltaTime; c.a = Mathf.Lerp(1f, 0f, t / dur); hurtRedImage.color = c; yield return null; } c.a = 0f; hurtRedImage.color = c; } public void ReceiveHeal(float amount, GameObject source) { int delta = Mathf.CeilToInt(amount); ModifyHP(delta, true); } public void ApplyBuff(Buff buff, GameObject source) { if (buff == null) return; activeBuffs.Add(buff); // simple immediate apply: multiply stats if (buff.attackMultiplier != 1f) { attack = Mathf.RoundToInt(attack * buff.attackMultiplier); } if (buff.scoreMultiplier != 1f) scoreEfficiency *= buff.scoreMultiplier; // UI/visual could be added } public void RemoveBuff(string buffId) { var b = activeBuffs.Find(x => x.buffId == buffId); if (b != null) { activeBuffs.Remove(b); if (b.scoreMultiplier != 1f) scoreEfficiency /= b.scoreMultiplier; // cannot easily restore attack without storing original; keep simple } } private void TryCastOnFullMana() { if (currentMana >= maxMana && Application.isPlaying) { // Ensure SkillBuilder singleton exists before attempting to cast. if (SkillBuilder.Instance == null) { Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will not attempt cast."); return; } int prevMana = currentMana; // Attempt to cast primary skill configured in SO via SkillBuilder CastSkill(); // Reset mana after casting currentMana = 0; // Update visuals using previous mana as old value so fade works UpdateManaVisuals(prevMana, true); } } private void CastSkill() { // Use SkillBuilder to invoke the primary skill defined in the AllyHero_SO for this slot if (SkillBuilder.Instance == null) { Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: SkillBuilder.Instance is null."); return; } var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so == null) { // Can't locate SO for this slot; attempt best-effort: call UsePrimarySkillForSlot which will log details Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1}: AllyHero_SO not found for slot. Falling back to UsePrimarySkillForSlot."); SkillBuilder.Instance.UsePrimarySkillForSlot(slotIndex, -1f, null); return; } var def = so.GetPrimarySkill(); if (def == null) { Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: primary skill not set in SO."); return; } // Only auto-cast here if the skill's trigger condition matches ManaFull if (def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull) { // Use the SkillDefinition API to ensure selector/resolution/operateDirectly are respected SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null); Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull."); } else { Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} primary skill '{def.skillId}' not set to trigger on ManaFull (trigger={def.triggerCondition}); skipping cast."); } } public void TriggerOnManaFull() { if (currentMana >= maxMana) { TryCastOnFullMana(); } } [ContextMenu("Test Fill Mana")] private void TestFillMana() { SetCurrentMana(maxMana, true); } }