using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; [ExecuteAlways] /// /// Documentation text normalized. /// Documentation text normalized. /// Documentation text normalized. /// Documentation text normalized. /// Documentation text normalized. /// public class AllyCombatant : MonoBehaviour, ICombatant { public int slotIndex = 0; // Documentation text normalized. // 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 public bool IsDead => isDead || currentHP <= 0; public BeatmapManager bmm; // --- Damage redirect (next hit) --- private static AllyCombatant s_nextDamageRedirector; private static float s_redirectExpireTime = -1f; private static string s_redirectIconId; private static int s_redirectIconSlotIndex = -1; // --- Self damage redirect to adjacent (duration) --- private float _selfDamageRedirectToAdjacentExpireTime = -1f; private string _selfDamageRedirectToAdjacentIconId; // add attack stat to be available at runtime [Tooltip("Documentation text normalized.")] public int attack = 0; // Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly. private int _attackBaseUnbuffed = 0; private bool _attackBaseInitialized = false; // scoring fields [Header("Scoring")] [Tooltip("Base score value for a perfect hit on this track")] public int baseTrackScore = 1000; // Editable multipliers for each judge quality (exposed for designers) [Tooltip("Score multiplier for Perfect (default 1.0)")] public float perfectRatio = 1f; [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("Documentation text normalized.")] public int currentScore = 0; [Tooltip("Documentation text normalized.")] 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; public string allyName => nameText != null ? nameText.text : name; private TextMeshProUGUI healthRateText; private TextMeshProUGUI currentScoreText; private Image hurtRedImage; // Gameplay HUD extras (legacy UI elements authored in the scene). // Do not modify their transforms/style; only update runtime data. private Text manaPercentTextLegacy; // child named "mana%" private Image[] skillIconSlots; // children under "skill_img" (max 3) private bool[] skillIconOccupied; private Vector3[] skillIconBaseScales; private bool[] skillIconBaseScaleCached; [Tooltip("最多显示的已释放技能图标数量 (默认1)")] public int maxSkillHistoryCount = 1; [Tooltip("无新技能释放时,技能图标自动渐隐的时间 (秒)")] public float skillIconAutoFadeTime = 2f; private Coroutine fadeHealthCoroutine; private Coroutine fadeManaCoroutine; private List activeBuffs = new List(); private Coroutine skillIconEnterCoroutine; private Image skillIconEnterAnimatingImage; private Coroutine skillIconAutoFadeCoroutine; private float lastSkillIconPushTime = -1f; // Track skills that are currently active because HP percent condition is satisfied. private HashSet _activeHpPercentSkills = new HashSet(); // Track applied reversible effects for HP-percent skills so we can revert them on leave private Dictionary _appliedHpPercentEffects = new Dictionary(); // Track skills that are currently active because Attack == 0 private HashSet _activeAttackZeroSkills = new HashSet(); private Dictionary _appliedAttackZeroEffects = new Dictionary(); // Track skills that are currently active because Attack is below a threshold (OnAttackBelowValue) private HashSet _activeAttackBelowSkills = new HashSet(); private Dictionary _appliedAttackBelowEffects = new Dictionary(); // Prevent re-entrant mana-trigger loops, e.g. OnManaGained -> (skill adds mana) -> OnManaGained -> ... private bool _isTriggeringManaGained = false; private bool _isTriggeringManaLost = false; // Prevent OnManaFull auto-cast recursion and allow "restore mana on spend" skills to work correctly. private bool _isCastingOnManaFull = false; private int _pendingManaGainAfterManaFullCast = 0; private int _lastManaFullCastFrame = -1; // Reusable buffers to reduce allocations during skill evaluation private readonly List _tmpSkillDefs = new List(16); private readonly Dictionary _formulaVarsBuffer = new Dictionary(16); private class AppliedEffect { public EffectType effectType; public float amount; public AppliedEffect(EffectType t, float a) { effectType = t; amount = a; } } private void Awake() { // ensure GameObject name matches pattern so EffectSystem can find it gameObject.name = $"ally_0{Mathf.Clamp(slotIndex + 1, 1, 5)}"; if (_activeHpPercentSkills == null) _activeHpPercentSkills = new HashSet(); if (_appliedHpPercentEffects == null) _appliedHpPercentEffects = new Dictionary(); if (_activeAttackZeroSkills == null) _activeAttackZeroSkills = new HashSet(); if (_appliedAttackZeroEffects == null) _appliedAttackZeroEffects = new Dictionary(); if (_activeAttackBelowSkills == null) _activeAttackBelowSkills = new HashSet(); if (_appliedAttackBelowEffects == null) _appliedAttackBelowEffects = new Dictionary(); } private void LogVerbose(string message) { if (GameConfig.verboseLogs) Debug.Log(message); } private void Update() { // Check for skill icon auto-fade if (Application.isPlaying && skillIconAutoFadeTime > 0f && lastSkillIconPushTime > 0f) { // Only start coroutine if not already running and time exceeded if (skillIconAutoFadeCoroutine == null && Time.time - lastSkillIconPushTime >= skillIconAutoFadeTime) { // Double check visibility bool anyVisible = false; if (skillIconOccupied != null) { for (int i = 0; i < skillIconOccupied.Length; i++) { if (skillIconOccupied[i]) { anyVisible = true; break; } } } if (anyVisible) { skillIconAutoFadeCoroutine = StartCoroutine(SkillIconAutoFadeRoutine()); } } } if (Application.isPlaying) { if (s_redirectExpireTime > 0f && Time.time > s_redirectExpireTime) ClearRedirect(); if (_selfDamageRedirectToAdjacentExpireTime > 0f && Time.time > _selfDamageRedirectToAdjacentExpireTime) { _selfDamageRedirectToAdjacentExpireTime = -1f; if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId); _selfDamageRedirectToAdjacentIconId = null; } } } } private void Start() { // Resolve UI and optionally pull data from SO ResolveUIReferences(); ResolveHudExtras(); if (allowOverwriteFromSO) InitializeStatsFromData(); // Documentation text normalized. // Documentation text normalized. isDead = false; currentHP = Mathf.Max(1, maxHP); currentMana = 0; UpdateUIImmediate(); // Ensure score UI shows initial value UpdateScoreUI(); iBudeffPrefabController.Instance?.RegisterBaseline(this); // Evaluate HP-percent triggers on start after a short delay to ensure singletons are ready if (Application.isPlaying) { StartCoroutine(InitialTriggerCheckCoroutine()); } } private IEnumerator InitialTriggerCheckCoroutine() { // Wait for singletons to be ready while (SkillBuilder.Instance == null || teamUIController.Instance == null) { yield return null; } // Wait until teamUIController has finished loading ally SOs if they are async // or just wait a couple of frames to be sure yield return null; yield return null; // Re-run initialization to ensure we have the correct MaxHP and skills from the loaded team if (allowOverwriteFromSO) { InitializeStatsFromData(); currentHP = Mathf.Max(1, maxHP); UpdateUIImmediate(); } iBudeffPrefabController.Instance?.RegisterBaseline(this); // Final wait to ensure SkillBuilder's internal mapping is ready while (SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex) == null) { yield return null; } // Perform the initial HP-percentage trigger check (oldHP = currentHP to trigger "ENTER" if condition met) EvaluateHPPercentageTriggers(currentHP); EvaluateAttackZeroTriggers(); EvaluateAttackBelowTriggers(); } // 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 currentHP or currentMana in inspector while in Play mode, attempt to trigger behaviors if (Application.isPlaying && SkillBuilder.Instance != null) { EvaluateHPPercentageTriggers(currentHP); EvaluateAttackZeroTriggers(); EvaluateAttackBelowTriggers(); TryCastOnFullMana(); } } private void ResolveUIReferences() { // Try singleton first, fallback to scene search (works in editor) var ui = teamUIController.Instance != null ? teamUIController.Instance : UnityEngine.Object.FindAnyObjectByType(); 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; } LogVerbose($"[AllyCombatant] Resolved currentScoreText for slot {slotIndex+1} from parent {parent.name} -> {tmp.gameObject.name}"); } else { // legacy UnityEngine.UI.Text fallback Text legacy = null; try { // Avoid grabbing gameplay HUD extras like "mana%" which must stay legacy and is not score text. var allLegacy = parent.GetComponentsInChildren(true); for (int k = 0; k < allLegacy.Length; k++) { var t = allLegacy[k]; if (t == null) continue; if (t.gameObject != null && t.gameObject.name == "mana%") continue; legacy = t; break; } } catch { } 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."); } } } } ResolveHudExtras(); } private void ResolveHudExtras() { // These elements are part of the ally's UI hierarchy in the gameplay scene. // Cache once to avoid per-frame GetComponent searches. try { if (manaPercentTextLegacy == null) { var t = transform.Find("mana%") ?? FindChildRecursive(transform, "mana%"); if (t != null) manaPercentTextLegacy = t.GetComponent(); } if (skillIconSlots == null || skillIconSlots.Length == 0) { var t = transform.Find("skill_img") ?? FindChildRecursive(transform, "skill_img"); if (t != null) { // Preserve authoring order (layout group will place them). var tmp = new List(8); for (int i = 0; i < t.childCount; i++) { var child = t.GetChild(i); if (child == null) continue; var img = child.GetComponent(); if (img != null) tmp.Add(img); } tmp.Sort(CompareSkillIconSlotsByScreenOrder); int take = Mathf.Min(3, tmp.Count); if (take > 0) { skillIconSlots = new Image[take]; for (int i = 0; i < take; i++) skillIconSlots[i] = tmp[i]; skillIconOccupied = new bool[take]; } } } EnsureSkillIconSlotScaleCache(); ApplySkillIconSlotVisibility(); } catch { } } private void EnsureSkillIconSlotScaleCache() { if (skillIconSlots == null || skillIconSlots.Length == 0) return; if (skillIconOccupied == null || skillIconOccupied.Length != skillIconSlots.Length) skillIconOccupied = new bool[skillIconSlots.Length]; int len = skillIconSlots.Length; if (skillIconBaseScales == null || skillIconBaseScales.Length != len) { skillIconBaseScales = new Vector3[len]; skillIconBaseScaleCached = new bool[len]; } for (int i = 0; i < len; i++) { if (skillIconBaseScaleCached[i]) continue; var img = skillIconSlots[i]; if (img == null || img.rectTransform == null) continue; skillIconBaseScales[i] = img.rectTransform.localScale; skillIconBaseScaleCached[i] = true; } } private int GetSkillIconSlotIndex(Image img) { if (img == null || skillIconSlots == null) return -1; for (int i = 0; i < skillIconSlots.Length; i++) { if (ReferenceEquals(skillIconSlots[i], img)) return i; } return -1; } private Vector3 GetSkillIconBaseScale(int slotIndex, Image img) { if (slotIndex >= 0 && skillIconBaseScales != null && skillIconBaseScaleCached != null && slotIndex < skillIconBaseScales.Length && slotIndex < skillIconBaseScaleCached.Length && skillIconBaseScaleCached[slotIndex]) { return skillIconBaseScales[slotIndex]; } if (img != null && img.rectTransform != null) return img.rectTransform.localScale; return Vector3.one; } private void NormalizeSkillIconSlotsVisualState() { if (skillIconSlots == null) return; EnsureSkillIconSlotScaleCache(); for (int i = 0; i < skillIconSlots.Length; i++) { var slot = skillIconSlots[i]; if (slot == null) continue; var rt = slot.rectTransform; if (rt != null) rt.localScale = GetSkillIconBaseScale(i, slot); } ApplySkillIconSlotVisibility(); } private void ApplySkillIconSlotVisibility() { if (skillIconSlots == null || skillIconOccupied == null) return; for (int i = 0; i < skillIconSlots.Length; i++) { Image slot = skillIconSlots[i]; if (slot == null) continue; // Enforce max count if (i >= maxSkillHistoryCount) { slot.enabled = false; continue; } bool occupied = i < skillIconOccupied.Length && skillIconOccupied[i]; slot.enabled = occupied || slot.sprite != null; Color c = slot.color; c.a = occupied ? 1f : 0f; slot.color = c; } ForceRebuildSkillIconLayout(); } private static int CompareSkillIconSlotsByScreenOrder(Image a, Image b) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; Vector3 pa = a.rectTransform != null ? a.rectTransform.position : a.transform.position; Vector3 pb = b.rectTransform != null ? b.rectTransform.position : b.transform.position; int xOrder = pa.x.CompareTo(pb.x); // left -> right on screen if (xOrder != 0) return xOrder; return -pa.y.CompareTo(pb.y); } private void ForceRebuildSkillIconLayout() { if (skillIconSlots == null || skillIconSlots.Length == 0) return; RectTransform parent = skillIconSlots[0] != null && skillIconSlots[0].rectTransform != null ? skillIconSlots[0].rectTransform.parent as RectTransform : null; if (parent == null) return; try { LayoutRebuilder.ForceRebuildLayoutImmediate(parent); } catch { } } private static Transform FindChildRecursive(Transform root, string name) { if (root == null || string.IsNullOrEmpty(name)) return null; for (int i = 0; i < root.childCount; i++) { var c = root.GetChild(i); if (c == null) continue; if (c.name == name) return c; var found = FindChildRecursive(c, name); if (found != null) return found; } return null; } private void UpdateManaPercentLegacyText() { if (manaPercentTextLegacy == null) return; int max = Mathf.Max(1, maxMana); int cur = Mathf.Clamp(currentMana, 0, max); int percent = cur <= 0 ? 0 : (cur * 100 + max - 1) / max; // ceil without floats manaPercentTextLegacy.text = percent.ToString() + "%"; } /// /// Push a skill icon into this ally's "skill_img" UI group (newest first, max 3). /// public void PushSkillIcon(Sprite icon) { if (icon == null) return; if (skillIconSlots == null || skillIconSlots.Length == 0) ResolveHudExtras(); if (skillIconSlots == null || skillIconSlots.Length == 0) return; // Reset auto-fade logic lastSkillIconPushTime = Time.time; if (skillIconAutoFadeCoroutine != null) { StopCoroutine(skillIconAutoFadeCoroutine); skillIconAutoFadeCoroutine = null; } int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length); if (limit <= 0) return; EnsureSkillIconSlotScaleCache(); NormalizeSkillIconSlotsVisualState(); // Capture the icon being pushed out (oldest active within limit) for exit animation. int lastIndex = limit - 1; Image lastSlot = skillIconSlots[lastIndex]; Sprite removedSprite = null; Color removedColor = Color.white; bool removedOccupied = skillIconOccupied != null && lastIndex < skillIconOccupied.Length && skillIconOccupied[lastIndex]; if (removedOccupied && lastSlot != null && lastSlot.sprite != null) { removedSprite = lastSlot.sprite; removedColor = lastSlot.color; } // Shift down (oldest drops off the end). for (int i = lastIndex; i > 0; i--) { var prev = skillIconSlots[i - 1]; var cur = skillIconSlots[i]; if (cur == null) continue; cur.sprite = prev != null ? prev.sprite : null; if (prev != null && skillIconOccupied != null && i - 1 < skillIconOccupied.Length) { cur.color = prev.color; bool occupied = skillIconOccupied[i - 1]; cur.enabled = occupied || cur.sprite != null; skillIconOccupied[i] = occupied; } else if (skillIconOccupied != null && i < skillIconOccupied.Length) { skillIconOccupied[i] = false; } } // Insert at front. var first = skillIconSlots[0]; if (first != null) { first.sprite = icon; first.enabled = true; first.color = Color.white; if (skillIconOccupied != null && skillIconOccupied.Length > 0) skillIconOccupied[0] = true; PlaySkillIconEnterAnim(first); } // Clear slots beyond limit for (int i = limit; i < skillIconSlots.Length; i++) { if (skillIconSlots[i] != null) { skillIconSlots[i].sprite = null; skillIconSlots[i].enabled = false; } if (skillIconOccupied != null && i < skillIconOccupied.Length) skillIconOccupied[i] = false; } ApplySkillIconSlotVisibility(); // Play exit animation for the removed icon (if any). if (removedSprite != null && lastSlot != null) { PlaySkillIconExitAnim(lastSlot, removedSprite, removedColor); } } private IEnumerator SkillIconAutoFadeRoutine() { if (skillIconSlots == null || skillIconOccupied == null) { skillIconAutoFadeCoroutine = null; yield break; } // Fading from Left to Right (0 -> N) // Assuming slot 0 is the newest and leftmost. // If the user meant visual order, 0 is usually left. int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length); for (int i = 0; i < limit; i++) { if (i >= skillIconOccupied.Length) break; // If new skill interrupts, this coroutine is stopped by PushSkillIcon if (skillIconOccupied[i] && skillIconSlots[i] != null && skillIconSlots[i].sprite != null) { // Play exit animation for this slot PlaySkillIconExitAnim(skillIconSlots[i], skillIconSlots[i].sprite, skillIconSlots[i].color); // Clear the slot skillIconSlots[i].sprite = null; skillIconSlots[i].enabled = false; skillIconOccupied[i] = false; // Wait small interval between fades yield return new WaitForSeconds(0.15f); } } ApplySkillIconSlotVisibility(); skillIconAutoFadeCoroutine = null; // Reset time so it doesn't loop immediately (though checks anyVisible) lastSkillIconPushTime = -1f; } private void PlaySkillIconEnterAnim(Image img) { if (img == null) return; if (!Application.isPlaying) return; if (skillIconEnterCoroutine != null) { if (skillIconEnterAnimatingImage != null && skillIconEnterAnimatingImage.rectTransform != null) { int prevIndex = GetSkillIconSlotIndex(skillIconEnterAnimatingImage); skillIconEnterAnimatingImage.rectTransform.localScale = GetSkillIconBaseScale(prevIndex, skillIconEnterAnimatingImage); var prevColor = skillIconEnterAnimatingImage.color; prevColor.a = 1f; skillIconEnterAnimatingImage.color = prevColor; } StopCoroutine(skillIconEnterCoroutine); } skillIconEnterAnimatingImage = img; skillIconEnterCoroutine = StartCoroutine(SkillIconEnterCoroutine(img)); } private IEnumerator SkillIconEnterCoroutine(Image img) { if (img == null) yield break; var rt = img.rectTransform; if (rt == null) yield break; int slotIndex = GetSkillIconSlotIndex(img); Vector3 baseScale = GetSkillIconBaseScale(slotIndex, img); Color baseColor = img.color; float duration = 0.24f; float startScaleMult = 1.32f; rt.localScale = baseScale * startScaleMult; img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f); float t = 0f; while (t < duration) { t += Time.deltaTime; float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration)); float eased = 1f - Mathf.Pow(1f - u, 3f); // quick start, smooth finish rt.localScale = Vector3.Lerp(baseScale * startScaleMult, baseScale, eased); yield return null; } rt.localScale = baseScale; img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f); if (ReferenceEquals(skillIconEnterAnimatingImage, img)) { skillIconEnterAnimatingImage = null; skillIconEnterCoroutine = null; } } private void PlaySkillIconExitAnim(Image hostSlot, Sprite removedSprite, Color removedColor) { if (hostSlot == null || removedSprite == null) return; if (!Application.isPlaying) return; // Render removed icon under the slot parent to avoid disturbing slot layout positions. var go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); RectTransform hostRt = hostSlot.rectTransform; RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null; go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false); var rt = go.GetComponent(); if (hostRt != null) { rt.anchorMin = hostRt.anchorMin; rt.anchorMax = hostRt.anchorMax; rt.pivot = hostRt.pivot; rt.sizeDelta = hostRt.sizeDelta; rt.anchoredPosition = hostRt.anchoredPosition; rt.localRotation = hostRt.localRotation; rt.localScale = hostRt.localScale; } else { rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = Vector2.zero; rt.sizeDelta = Vector2.zero; } var img = go.GetComponent(); img.raycastTarget = false; img.sprite = removedSprite; img.color = removedColor; StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot)); } private IEnumerator SkillIconExitCoroutine(GameObject go, RectTransform rt, Image img, Color baseColor, Image hostSlot) { if (go == null || rt == null || img == null) yield break; float duration = 0.45f; float t = 0f; Vector2 startPos = rt.anchoredPosition; // Move outward relative to the queue direction so removed icon does not overlap the new tail icon. Vector2 exitDir = Vector2.right; float slotGap = 0f; int hostIndex = GetSkillIconSlotIndex(hostSlot); if (hostIndex > 0 && skillIconSlots != null && hostIndex < skillIconSlots.Length) { var prev = skillIconSlots[hostIndex - 1]; if (prev != null && prev.rectTransform != null && hostSlot != null && hostSlot.rectTransform != null) { Vector2 prevPos = prev.rectTransform.anchoredPosition; Vector2 hostPos = hostSlot.rectTransform.anchoredPosition; Vector2 queueDir = hostPos - prevPos; slotGap = queueDir.magnitude; if (queueDir.sqrMagnitude > 0.0001f) exitDir = queueDir.normalized; } } if (exitDir.sqrMagnitude < 0.0001f) exitDir = Vector2.right; float iconWidth = Mathf.Max(1f, rt.rect.width * Mathf.Max(0.01f, Mathf.Abs(rt.localScale.x))); float slideDistance = Mathf.Max(36f, iconWidth * 1.15f, slotGap * 1.35f); Vector2 endPos = startPos + exitDir * slideDistance; while (t < duration) { t += Time.deltaTime; float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration)); float eased = 1f - Mathf.Pow(1f - u, 3f); rt.anchoredPosition = Vector2.Lerp(startPos, endPos, eased); var c = baseColor; c.a = Mathf.Lerp(baseColor.a, 0f, u); img.color = c; yield return null; } if (go != null) Destroy(go); } 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) { // Documentation text normalized. // Documentation text normalized. // Documentation text normalized. int expKeySlot = Mathf.Clamp(slotIndex + 1, 1, 5); string expKey = $"selected_heroSlot0{expKeySlot}_exp"; int slotExp = a.ally_currentEXP; if (PlayerPrefs.HasKey(expKey)) slotExp = PlayerPrefs.GetInt(expKey, a.ally_currentEXP); // pick effective level based on per-slot EXP if (a.levelStats != null && a.levelStats.Count > 0) { var eff = GetEffectiveLevelForExp(a, slotExp); if (eff != null) { maxHP = eff.maxHP; maxMana = eff.maxMana; damageResistance = eff.damageResistance; scoreEfficiency = eff.scoreEfficiency; SetAttack(eff.attack); } else { var lvl = a.levelStats[0]; maxHP = lvl.maxHP; maxMana = lvl.maxMana; damageResistance = lvl.damageResistance; scoreEfficiency = lvl.scoreEfficiency; SetAttack(lvl.attack); } } if (nameText != null) nameText.text = a.ally_heroName; if (characterImage != null && a.ally_heroProfile != null) characterImage.sprite = a.ally_heroProfile; break; } } } } // Documentation text normalized. private static AllyHero_SO.AllyLevelInfo GetEffectiveLevelForExp(AllyHero_SO so, int exp) { if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null; AllyHero_SO.AllyLevelInfo best = null; foreach (var lvl in so.levelStats) { if (lvl == null) continue; if (exp >= lvl.requiredEXP) { if (best == null || lvl.requiredEXP >= best.requiredEXP) best = lvl; } } return best ?? so.levelStats[0]; } // Scoring API // Modified to return the amount added so callers can track per-track contributions public int AddScoreForJudge(string judge) { if (IsDead) return 0; // dead characters cannot gain score int add = 0; float multiplier = 0f; switch (judge) { case "Perfect": multiplier = perfectRatio; break; case "Great": multiplier = greatRatio; break; case "Good": multiplier = goodRatio; break; case "Miss": multiplier = missRatio; break; default: multiplier = 0f; break; } // Use perNoteScore from BeatmapManager as the base score for each note hit int baseScore = bmm != null ? bmm.perNoteScore : baseTrackScore; add = Mathf.FloorToInt(baseScore * multiplier); int before = currentScore; currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore); int actuallyAdded = currentScore - before; UpdateScoreUI(); // update global total ScoreManager.Instance?.RecalculateTotal(); EvaluateAttackZeroTriggers(); return actuallyAdded; } // Add an arbitrary score delta directly (used by skills to modify single judge or grant/penalize score) public void AddScoreDirect(int delta) { if (IsDead) return; // dead characters cannot gain/lose score currentScore = Mathf.Clamp(currentScore + delta, 0, maxTrackScore); UpdateScoreUI(); ScoreManager.Instance?.RecalculateTotal(); } private void UpdateScoreUI() { string value = $"{currentScore}/{maxTrackScore}"; if (currentScoreText != null) { currentScoreText.text = value; LogVerbose($"[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; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate01_current_scoreText: {value}"); } break; case 1: if (ui.teammate02_current_scoreText != null) { ui.teammate02_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate02_current_scoreText: {value}"); } break; case 2: if (ui.teammate03_current_scoreText != null) { ui.teammate03_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate03_current_scoreText: {value}"); } break; case 3: if (ui.teammate04_current_scoreText != null) { ui.teammate04_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate04_current_scoreText: {value}"); } break; case 4: if (ui.teammate05_current_scoreText != null) { ui.teammate05_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate05_current_scoreText: {value}"); } break; } } } // Set max score cap for this track and refresh UI public void SetMaxTrackScore(int newMax, bool clampCurrent = true) { maxTrackScore = Mathf.Max(0, newMax); if (clampCurrent) currentScore = Mathf.Clamp(currentScore, 0, maxTrackScore); UpdateScoreUI(); } // 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); // Trigger HP changed events int delta = currentHP - old; if (delta > 0) { TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPHealed); } else if (delta < 0) { TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPLost); } // Defeated event (fire once on transition >0 -> 0) if (old > 0 && currentHP <= 0) { TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnSelfDefeated); string dName = "Unknown"; if (nameText != null) dName = nameText.text; else if (SkillBuilder.Instance != null) { var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so != null) dName = so.ally_heroName; } SkillTriggerFeedUI.PushDeath(dName); } // Evaluate HP-percentage based trigger transitions (entry/exit) EvaluateHPPercentageTriggers(old); } // Internal HP modifier that does NOT apply heal multipliers (used by ReceiveHeal to avoid double rounding). private void ModifyHPRaw(int delta, bool animateFade = true) { if (isDead && delta > 0) { // cannot heal a dead unit return; } SetCurrentHP(currentHP + delta, animateFade); } public void ModifyHP(int delta, bool animateFade = true) { if (isDead && delta > 0) { // cannot heal a dead unit return; } // Apply heal-received multipliers to all positive HP deltas so both ReceiveHeal() and direct ModifyHP(+) // paths respect heal reduction / amplification buffs. if (delta > 0) { float mult = GetTotalHealReceivedMultiplier(); if (!Mathf.Approximately(mult, 1f)) { delta = Mathf.CeilToInt(delta * mult); } } ModifyHPRaw(delta, animateFade); } public void SetMaxHP(int newMax, bool keepCurrentRatio = true) { if (newMax < 1) newMax = 1; int oldMax = maxHP; int oldHP = currentHP; 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(); // Max HP change affects percentage triggers EvaluateHPPercentageTriggers(oldHP); } public void SetCurrentMana(int mana, bool animateFade = true, bool flashVisuals = true) { if (isDead) return; // dead characters cannot change mana int old = currentMana; currentMana = Mathf.Clamp(mana, 0, maxMana); UpdateManaVisuals(old, animateFade, flashVisuals); // Only auto-cast when we actually reach full mana from below. if (old < maxMana && currentMana >= maxMana) { TryCastOnFullMana(); } // Trigger mana changed events int delta = currentMana - old; if (delta > 0) { if (_isTriggeringManaGained) return; _isTriggeringManaGained = true; try { TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaGained); } finally { _isTriggeringManaGained = false; } } else if (delta < 0) { if (_isTriggeringManaLost) return; _isTriggeringManaLost = true; try { TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaLost); } finally { _isTriggeringManaLost = false; } } } public void ModifyMana(int delta, bool animateFade = true, bool flashVisuals = true) { if (isDead && delta > 0) { // dead units cannot gain mana return; } // If a skill tries to add mana to the caster while they're still at full mana during an OnManaFull cast, // the increase would be clamped (wasted) and could also cause re-entrant casting. Buffer it and apply // after the cast spends mana. if (_isCastingOnManaFull && delta > 0 && currentMana >= maxMana) { _pendingManaGainAfterManaFullCast += delta; return; } SetCurrentMana(currentMana + delta, animateFade, flashVisuals); } public void SetMaxMana(int newMax, bool keepCurrentRatio = true) { if (newMax < 1) newMax = 1; int oldMax = maxMana; int oldMana = currentMana; 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(); // Mana doesn't currently have percentage triggers, but for consistency: // EvaluateHPPercentageTriggers(currentHP); // Not needed for mana but keep in mind } private void EnsureAttackBaseInitialized() { if (_attackBaseInitialized) return; _attackBaseInitialized = true; _attackBaseUnbuffed = Mathf.Max(0, attack); } private float GetTotalAttackMultiplier() { float mult = 1f; for (int i = 0; i < activeBuffs.Count; i++) { var b = activeBuffs[i]; if (b == null) continue; mult *= b.attackMultiplier; } return mult; } private float GetTotalHealReceivedMultiplier() { float mult = 1f; for (int i = 0; i < activeBuffs.Count; i++) { var b = activeBuffs[i]; if (b == null) continue; mult *= b.healReceivedMultiplier; } return mult; } public float GetHealReceivedMultiplierForUI() { return GetTotalHealReceivedMultiplier(); } private void RecalculateAttackFromBuffs() { EnsureAttackBaseInitialized(); float mult = GetTotalAttackMultiplier(); int old = attack; attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult)); if (old != attack) { EvaluateAttackZeroTriggers(); EvaluateAttackBelowTriggers(); } } public void ModifyAttack(int delta) { EnsureAttackBaseInitialized(); _attackBaseUnbuffed = Mathf.Max(0, _attackBaseUnbuffed + delta); RecalculateAttackFromBuffs(); } public void SetAttack(int value) { EnsureAttackBaseInitialized(); _attackBaseUnbuffed = Mathf.Max(0, value); RecalculateAttackFromBuffs(); } 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; } // HP change flash effects if (hurtRedImage != null && Application.isPlaying) { var ui = teamUIController.Instance; if (ui != null) { if (currentHP < oldHP) { // Damage flash (Red) if (!isDead) StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.hurtColor)); } else if (currentHP > oldHP) { // Heal flash (Green) StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.healColor)); } } } // if dead, trigger grayscale effect on character image if (isDead && characterImage != null && Application.isPlaying) { var ui = teamUIController.Instance; if (ui != null) { // Documentation text normalized. StartCoroutine(ui.AllyDeathGrayscale(characterImage)); } } } private void UpdateManaVisuals(int oldMana, bool animateFade, bool flashVisuals = true) { // 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; } // Mana recovery flash effect if (flashVisuals && currentMana > oldMana && hurtRedImage != null && Application.isPlaying) { var ui = teamUIController.Instance; if (ui != null) { StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.manaColor)); } } UpdateManaPercentLegacyText(); } 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}"; } UpdateManaPercentLegacyText(); } 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) { ReceiveDamageInternal(amount, source, true); } private void ReceiveDamageInternal(float amount, GameObject source, bool allowRedirect) { if (IsDead) return; // dead characters cannot be hit if (allowRedirect && IsRedirectActive()) { var redirector = s_nextDamageRedirector; ClearRedirect(); if (redirector != null && redirector != this) { redirector.ReceiveDamageInternal(amount, source, false); return; } // If redirector is self or invalid, consume redirect and continue to take damage normally. } // Self damage redirection: while active, redirect damage that would hit THIS ally to an adjacent ally. // Do not redirect self-inflicted costs (source == this.gameObject) to avoid breaking "pay HP" skills. if (allowRedirect && IsSelfDamageRedirectToAdjacentActive() && source != this.gameObject) { var adj = FindAdjacentRedirectTarget(); if (adj != null && adj != this) { adj.ReceiveDamageInternal(amount, source, false); return; } } float effective = amount * (1f - damageResistance); int delta = Mathf.CeilToInt(effective); ModifyHP(-delta, true); } public void ReceiveHeal(float amount, GameObject source) { if (IsDead) return; // dead characters cannot be healed float mult = GetTotalHealReceivedMultiplier(); int delta = Mathf.CeilToInt(amount * mult); ModifyHPRaw(delta, true); } public void ApplyBuff(Buff buff, GameObject source) { if (buff == null) return; activeBuffs.Add(buff); // Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted. if (buff.scoreMultiplier != 1f) scoreEfficiency *= buff.scoreMultiplier; if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs(); iBudeffPrefabController.Instance?.NotifyBuffApplied(this, buff); } 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; if (b.attackMultiplier != 1f) RecalculateAttackFromBuffs(); iBudeffPrefabController.Instance?.NotifyBuffRemoved(this, b); } } private void TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger when) { if (!Application.isPlaying) return; if (SkillBuilder.Instance == null) return; var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so == null) return; // If equipped groups exist, iterate them first if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null) { foreach (var gid in so.equippedSkillGroupIDs) { if (gid == 0) continue; SkillGroup group = null; for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } } if (group == null) continue; foreach (var def in group.skills) { if (def == null) continue; if (def.triggerCondition != when) continue; SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null); } } return; } // Fallback: check primary group var fallbackGroup = so.GetPrimarySkillGroup(); if (fallbackGroup != null) { foreach (var def in fallbackGroup.skills) { if (def == null) continue; if (def.triggerCondition != when) continue; SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null); } return; } // Final fallback: iterate availableSkills if (so.availableSkills != null) { foreach (var def in so.availableSkills) { if (def == null) continue; if (def.triggerCondition != when) continue; SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null); } } } private void TryCastOnFullMana() { if (!Application.isPlaying) return; if (_isCastingOnManaFull) return; if (currentMana < maxMana) return; // Safety: avoid infinite loops if a cast immediately refills mana to full in the same frame. if (_lastManaFullCastFrame == Time.frameCount) return; _lastManaFullCastFrame = Time.frameCount; if (SkillBuilder.Instance == null) { Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will not attempt cast."); return; } _isCastingOnManaFull = true; _pendingManaGainAfterManaFullCast = 0; try { // Attempt to cast primary skill configured in SO via SkillBuilder CastSkill(); // Reset mana after casting via SetCurrentMana so "OnManaLost" triggers include this spend. // (TryCastOnFullMana directly assigning currentMana would bypass mana-change events.) SetCurrentMana(0, true, false); } finally { _isCastingOnManaFull = false; } // Apply any buffered mana gains now that mana has been spent. if (_pendingManaGainAfterManaFullCast != 0) { int pending = _pendingManaGainAfterManaFullCast; _pendingManaGainAfterManaFullCast = 0; ModifyMana(pending, true, 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; } bool anyTriggered = false; // First: equipped groups if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null) { foreach (int gid in so.equippedSkillGroupIDs) { if (gid == 0) continue; SkillGroup group = null; for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } } if (group == null) continue; foreach (var skill in group.skills) { if (skill == null) continue; if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue; SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null); LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId} (OnManaFull)"); anyTriggered = true; } } } // Second: primary group if (!anyTriggered) { var fallbackGroup = so.GetPrimarySkillGroup(); if (fallbackGroup != null) { foreach (var skill in fallbackGroup.skills) { if (skill == null) continue; if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue; SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null); LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from primary group: {skill.skillId} (OnManaFull)"); anyTriggered = true; } } } // Third: availableSkills / primary skill fallback if (!anyTriggered) { // try primary skill specifically var def = so.GetPrimarySkill(); if (def != null && def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull) { SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null); LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull."); anyTriggered = true; } else if (so.availableSkills != null) { foreach (var skill in so.availableSkills) { if (skill == null) continue; if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue; SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null); LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast available skill: {skill.skillId} (OnManaFull)"); anyTriggered = true; } } } if (!anyTriggered) { LogVerbose($"[AllyCombatant] Slot {slotIndex + 1}: no skills configured for OnManaFull."); } } private void CastSkillGroup(SkillGroup group, int slotIndex) { if (group == null) return; // For auto-casting triggered by ManaFull, only cast skills that are configured to trigger on ManaFull. for (int i = 0; i < group.skills.Length; i++) { var skill = group.skills[i]; if (skill == null) continue; // Only cast skills whose triggerCondition matches OnManaFull when this method is invoked from auto-cast path if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) { LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} skill '{skill.skillId}' skipped: triggerCondition={skill.triggerCondition}."); continue; } SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null); LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId}"); } } public void TriggerOnManaFull() { if (currentMana >= maxMana) { TryCastOnFullMana(); } } [ContextMenu("Test Fill Mana")] private void TestFillMana() { SetCurrentMana(maxMana, true); } private Dictionary BuildFormulaVars(AllyHero_SO so) { var vars = _formulaVarsBuffer; vars.Clear(); vars["slot"] = slotIndex; // Use runtime values as formula variables, so buffs/debuffs and max stat changes affect formulas. vars["attack"] = attack; vars["maxHP"] = maxHP; vars["maxMana"] = maxMana; vars["damageResistance"] = damageResistance; vars["scoreEfficiency"] = scoreEfficiency; if (so != null && so.levelStats != null && so.levelStats.Count > 0) { var eff = so.GetEffectiveLevelForCurrentEXP(); var lvl = eff ?? so.levelStats[0]; vars["level"] = lvl.levelID; vars["levelID"] = lvl.levelID; vars["currentLevel"] = lvl.levelID; } else { vars["level"] = 0f; vars["levelID"] = 0f; vars["currentLevel"] = 0f; } vars["ally_currentEXP"] = so != null ? so.ally_currentEXP : 0f; vars["currentMana"] = currentMana; vars["currentScore"] = currentScore; vars["idolScore"] = currentScore; vars["currentHP"] = currentHP; return vars; } // New: Evaluate HP-percent triggers and handle enter/leave transitions private void EvaluateHPPercentageTriggers(int oldHP) { if (!Application.isPlaying) return; if (SkillBuilder.Instance == null) return; var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so == null) return; float oldPct = (maxHP > 0) ? (float)oldHP / (float)maxHP : 0f; float currPct = (maxHP > 0) ? (float)currentHP / (float)maxHP : 0f; // Collect candidate skills (same priority order as other triggers) var defs = _tmpSkillDefs; defs.Clear(); if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null) { foreach (var gid in so.equippedSkillGroupIDs) { if (gid == 0) continue; SkillGroup group = null; for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } } if (group == null || group.skills == null) continue; for (int i = 0; i < group.skills.Length; i++) { var def = group.skills[i]; if (def != null) defs.Add(def); } } } else { var fallbackGroup = so.GetPrimarySkillGroup(); if (fallbackGroup != null && fallbackGroup.skills != null) { for (int i = 0; i < fallbackGroup.skills.Length; i++) { var def = fallbackGroup.skills[i]; if (def != null) defs.Add(def); } } else if (so.availableSkills != null) { for (int i = 0; i < so.availableSkills.Length; i++) { var def = so.availableSkills[i]; if (def != null) defs.Add(def); } } } var vars = BuildFormulaVars(so); // Select a single "best" Above/Below skill to avoid stacking when multiple thresholds are satisfied. string selectedAbove = null; float selectedAboveThreshold = float.MinValue; string selectedBelow = null; float selectedBelowThreshold = float.MaxValue; for (int i = 0; i < defs.Count; i++) { var def = defs[i]; if (def == null) continue; if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent) { float threshold = def.hpTriggerPercent; if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula)) { if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th; } threshold = Mathf.Clamp01(threshold); if (currPct >= threshold && threshold >= selectedAboveThreshold) { selectedAboveThreshold = threshold; selectedAbove = def.skillId; } } else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent) { float threshold = def.hpTriggerPercent; if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula)) { if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th; } threshold = Mathf.Clamp01(threshold); if (currPct <= threshold && threshold <= selectedBelowThreshold) { selectedBelowThreshold = threshold; selectedBelow = def.skillId; } } } for (int i = 0; i < defs.Count; i++) { var def = defs[i]; if (def == null) continue; if (def.triggerCondition != SkillDefinition.SkillTrigger.OnHPAbovePercent && def.triggerCondition != SkillDefinition.SkillTrigger.OnHPBelowPercent) continue; float threshold = def.hpTriggerPercent; if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula)) { if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th; } threshold = Mathf.Clamp01(threshold); bool wasActive = _activeHpPercentSkills.Contains(def.skillId); bool nowActive = false; if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent) nowActive = (selectedAbove != null && def.skillId == selectedAbove) && currPct >= threshold; else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent) nowActive = (selectedBelow != null && def.skillId == selectedBelow) && currPct <= threshold; // Evaluate formula into amount using similar variables as SkillBuilder float amount = 0f; if (!string.IsNullOrWhiteSpace(def.formula)) { if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult)) amount = fresult; } // Decide if this effect type is reversible (state-based) bool isReversible = IsEffectReversible(def.effectType); if (!isReversible) { // For non-reversible effects (like Damage or instant Heal), we don't track state // Just trigger it once when the condition is first met if (!wasActive && nowActive) { _activeHpPercentSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); } else if (wasActive && !nowActive) { _activeHpPercentSkills.Remove(def.skillId); } continue; } if (!wasActive && nowActive) { // ENTER condition: Apply effect and record amount _activeHpPercentSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); _appliedHpPercentEffects[def.skillId] = new AppliedEffect(def.effectType, amount); } else if (wasActive && !nowActive) { // LEAVE condition: Revert effect using recorded amount _activeHpPercentSkills.Remove(def.skillId); if (_appliedHpPercentEffects.TryGetValue(def.skillId, out var applied)) { ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount); _appliedHpPercentEffects.Remove(def.skillId); } } } } private void EvaluateAttackZeroTriggers() { if (!Application.isPlaying) return; if (SkillBuilder.Instance == null) return; var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so == null) return; bool nowActive = attack <= 0; var defs = _tmpSkillDefs; defs.Clear(); if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null) { foreach (var gid in so.equippedSkillGroupIDs) { if (gid == 0) continue; SkillGroup group = null; for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } } if (group == null || group.skills == null) continue; for (int i = 0; i < group.skills.Length; i++) { var def = group.skills[i]; if (def != null) defs.Add(def); } } } else { var fallbackGroup = so.GetPrimarySkillGroup(); if (fallbackGroup != null && fallbackGroup.skills != null) { for (int i = 0; i < fallbackGroup.skills.Length; i++) { var def = fallbackGroup.skills[i]; if (def != null) defs.Add(def); } } else if (so.availableSkills != null) { for (int i = 0; i < so.availableSkills.Length; i++) { var def = so.availableSkills[i]; if (def != null) defs.Add(def); } } } var vars = BuildFormulaVars(so); for (int i = 0; i < defs.Count; i++) { var def = defs[i]; if (def == null) continue; if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackZero) continue; bool wasActive = _activeAttackZeroSkills.Contains(def.skillId); // Evaluate formula into amount using similar variables as SkillBuilder float amount = 0f; if (!string.IsNullOrWhiteSpace(def.formula)) { if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult)) amount = 0f; else amount = fresult; } bool isReversible = IsEffectReversible(def.effectType); if (!isReversible) { if (!wasActive && nowActive) { _activeAttackZeroSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); } else if (wasActive && !nowActive) { _activeAttackZeroSkills.Remove(def.skillId); } continue; } if (!wasActive && nowActive) { _activeAttackZeroSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); _appliedAttackZeroEffects[def.skillId] = new AppliedEffect(def.effectType, amount); } else if (wasActive && !nowActive) { _activeAttackZeroSkills.Remove(def.skillId); if (_appliedAttackZeroEffects.TryGetValue(def.skillId, out var applied)) { ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount); _appliedAttackZeroEffects.Remove(def.skillId); } } } } private void EvaluateAttackBelowTriggers() { if (!Application.isPlaying) return; if (SkillBuilder.Instance == null) return; var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex); if (so == null) return; int currAttack = attack; var defs = _tmpSkillDefs; defs.Clear(); if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null) { foreach (var gid in so.equippedSkillGroupIDs) { if (gid == 0) continue; SkillGroup group = null; for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } } if (group == null || group.skills == null) continue; for (int i = 0; i < group.skills.Length; i++) { var def = group.skills[i]; if (def != null) defs.Add(def); } } } else { var fallbackGroup = so.GetPrimarySkillGroup(); if (fallbackGroup != null && fallbackGroup.skills != null) { for (int i = 0; i < fallbackGroup.skills.Length; i++) { var def = fallbackGroup.skills[i]; if (def != null) defs.Add(def); } } else if (so.availableSkills != null) { for (int i = 0; i < so.availableSkills.Length; i++) { var def = so.availableSkills[i]; if (def != null) defs.Add(def); } } } // Pick the most restrictive threshold that is still satisfied (smallest attackTriggerValue such that attack < value). string selected = null; int selectedThreshold = int.MaxValue; for (int i = 0; i < defs.Count; i++) { var def = defs[i]; if (def == null) continue; if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue; int thr = def.attackTriggerValue; if (thr < 0) thr = 0; if (currAttack < thr && thr <= selectedThreshold) { selectedThreshold = thr; selected = def.skillId; } } var vars = BuildFormulaVars(so); for (int i = 0; i < defs.Count; i++) { var def = defs[i]; if (def == null) continue; if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue; int thr = def.attackTriggerValue; if (thr < 0) thr = 0; bool wasActive = _activeAttackBelowSkills.Contains(def.skillId); bool nowActive = (selected != null && def.skillId == selected) && (currAttack < thr); float amount = 0f; if (!string.IsNullOrWhiteSpace(def.formula)) { if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult)) amount = fresult; } bool isReversible = IsEffectReversible(def.effectType); if (!isReversible) { if (!wasActive && nowActive) { _activeAttackBelowSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); } else if (wasActive && !nowActive) { _activeAttackBelowSkills.Remove(def.skillId); } continue; } if (!wasActive && nowActive) { _activeAttackBelowSkills.Add(def.skillId); EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null); _appliedAttackBelowEffects[def.skillId] = new AppliedEffect(def.effectType, amount); } else if (wasActive && !nowActive) { _activeAttackBelowSkills.Remove(def.skillId); if (_appliedAttackBelowEffects.TryGetValue(def.skillId, out var applied)) { ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount); _appliedAttackBelowEffects.Remove(def.skillId); } } } } public static void ActivateNextDamageRedirect(AllyCombatant redirector, float duration = 0f) { if (redirector == null) return; if (!string.IsNullOrEmpty(s_redirectIconId) && s_redirectIconSlotIndex >= 0) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(s_redirectIconSlotIndex, s_redirectIconId); s_redirectIconId = null; s_redirectIconSlotIndex = -1; } s_nextDamageRedirector = redirector; if (duration > 0f && Application.isPlaying) s_redirectExpireTime = Time.time + duration; else s_redirectExpireTime = -1f; if (Application.isPlaying) { s_redirectIconSlotIndex = redirector.slotIndex; s_redirectIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(redirector, PlayerBudeffIconType.dmg_redirect_toSelf, 0f, duration); } } private static bool IsRedirectActive() { if (s_nextDamageRedirector == null) return false; if (Application.isPlaying && s_redirectExpireTime > 0f && Time.time > s_redirectExpireTime) { ClearRedirect(); return false; } if (s_nextDamageRedirector != null && s_nextDamageRedirector.IsDead) { ClearRedirect(); return false; } return true; } private static void ClearRedirect() { if (!string.IsNullOrEmpty(s_redirectIconId) && s_redirectIconSlotIndex >= 0) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(s_redirectIconSlotIndex, s_redirectIconId); s_redirectIconId = null; s_redirectIconSlotIndex = -1; } s_nextDamageRedirector = null; s_redirectExpireTime = -1f; } public void ActivateSelfDamageRedirectToAdjacent(float duration) { if (!Application.isPlaying) return; if (duration <= 0f) duration = 0.01f; _selfDamageRedirectToAdjacentExpireTime = Time.time + duration; if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId); _selfDamageRedirectToAdjacentIconId = null; } _selfDamageRedirectToAdjacentIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(this, PlayerBudeffIconType.dmg_redirect_toAdjacent, 0f, duration); } private bool IsSelfDamageRedirectToAdjacentActive() { if (!Application.isPlaying) return false; if (_selfDamageRedirectToAdjacentExpireTime <= 0f) return false; if (Time.time > _selfDamageRedirectToAdjacentExpireTime) { _selfDamageRedirectToAdjacentExpireTime = -1f; return false; } return true; } private AllyCombatant FindAdjacentRedirectTarget() { // Choose an adjacent ally (left first, then right) that is alive and active. var ui = teamUIController.Instance; if (ui != null) { int[] adj = ui.GetAdjacentAllyIndices(slotIndex); for (int i = 0; i < adj.Length; i++) { int idx = adj[i]; GameObject go = ui.GetAllyObjectBySlot(idx) ?? GameObject.Find($"ally_0{idx + 1}"); if (go == null) continue; var ally = go.GetComponent() ?? go.GetComponentInChildren(true); if (ally == null) continue; if (ally == this) continue; if (ally.IsDead) continue; if (!ally.gameObject.activeInHierarchy) continue; return ally; } return null; } int left = slotIndex - 1; int right = slotIndex + 1; if (left >= 0) { var go = GameObject.Find($"ally_0{left + 1}"); var ally = go != null ? (go.GetComponent() ?? go.GetComponentInChildren(true)) : null; if (ally != null && ally != this && !ally.IsDead && ally.gameObject.activeInHierarchy) return ally; } if (right <= 4) { var go = GameObject.Find($"ally_0{right + 1}"); var ally = go != null ? (go.GetComponent() ?? go.GetComponentInChildren(true)) : null; if (ally != null && ally != this && !ally.IsDead && ally.gameObject.activeInHierarchy) return ally; } return null; } private bool IsEffectReversible(EffectType type) { switch (type) { case EffectType.IncreaseAttack: case EffectType.DecreaseAttack: case EffectType.IncreaseMaxHP: case EffectType.DecreaseMaxHP: case EffectType.IncreaseMaxMana: case EffectType.DecreaseMaxMana: case EffectType.IncreaseScoreEfficiency: case EffectType.DecreaseScoreEfficiency: case EffectType.IncreaseDamageResistance: case EffectType.DecreaseDamageResistance: case EffectType.ScoreMultiplier: case EffectType.AddScore: return true; default: return false; } } private void ApplyInverseEffect(Selector selector, EffectType type, float amount) { if (EffectSystem.Instance == null) return; switch (type) { case EffectType.IncreaseAttack: EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseAttack, amount, 0f, this.gameObject, null); break; case EffectType.DecreaseAttack: EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseAttack, amount, 0f, this.gameObject, null); break; case EffectType.IncreaseMaxHP: EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxHP, amount, 0f, this.gameObject, null); break; case EffectType.DecreaseMaxHP: EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxHP, amount, 0f, this.gameObject, null); break; case EffectType.IncreaseMaxMana: EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxMana, amount, 0f, this.gameObject, null); break; case EffectType.DecreaseMaxMana: EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxMana, amount, 0f, this.gameObject, null); break; case EffectType.IncreaseScoreEfficiency: EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseScoreEfficiency, amount, 0f, this.gameObject, null); break; case EffectType.DecreaseScoreEfficiency: EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseScoreEfficiency, amount, 0f, this.gameObject, null); break; case EffectType.IncreaseDamageResistance: EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseDamageResistance, amount, 0f, this.gameObject, null); break; case EffectType.DecreaseDamageResistance: EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseDamageResistance, amount, 0f, this.gameObject, null); break; case EffectType.ScoreMultiplier: if (Mathf.Abs(amount) > 0.0001f) EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, 1f / amount, 0f, this.gameObject, null); break; case EffectType.AddScore: EffectSystem.Instance.ApplyEffect(selector, EffectType.AddScore, -amount, 0f, this.gameObject, null); break; default: // Fallback: apply same type with negative amount if it's likely a numeric stat EffectSystem.Instance.ApplyEffect(selector, type, -amount, 0f, this.gameObject, null); break; } } }