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/buff redirect from adjacent ally to self (one-shot per redirector) --- private class NextDamageRedirectState { public AllyCombatant redirector; public float expireTime; public string iconId; public long activationOrder; } private static readonly List s_nextDamageRedirectStates = new List(8); private static long s_nextDamageRedirectOrderSeed = 0; // --- Self damage redirect to adjacent (duration) --- private float _selfDamageRedirectToAdjacentExpireTime = -1f; private string _selfDamageRedirectToAdjacentIconId; // --- Lucky Chance: rewrite non-Miss judges to Perfect (duration) --- private float _rewriteNonMissToPerfectExpireTime = -1f; private string _rewriteNonMissToPerfectIconId; // 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(); // original receiver -> actual redirected owner by buffId, used for later RemoveBuff routing. private readonly Dictionary _forwardedBuffRemoveTargets = new Dictionary(); private readonly Dictionary _buffAppliedTimes = new Dictionary(); 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; private bool _pendingManaFullCastRetry = false; // 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 List<(iNumberPrefabController.InstantNumberType type, int val)> _queuedPopups = new List<(iNumberPrefabController.InstantNumberType, int)>(); public void QueueDamagePopup(iNumberPrefabController.InstantNumberType type, int val) { _queuedPopups.Add((type, val)); } public void TriggerQueuedPopups() { foreach (var p in _queuedPopups) { iNumberPrefabController.SpawnForAllyStatic(slotIndex, p.type, p.val); } _queuedPopups.Clear(); } 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) { CleanupGlobalRedirects(); if (_pendingManaFullCastRetry && !_isCastingOnManaFull && currentMana >= maxMana && SkillBuilder.Instance != null) { _pendingManaFullCastRetry = false; TryCastOnFullMana(); } if (_selfDamageRedirectToAdjacentExpireTime > 0f && Time.time > _selfDamageRedirectToAdjacentExpireTime) { ConsumeSelfDamageRedirectToAdjacent(); } if (_rewriteNonMissToPerfectExpireTime > 0f && Time.time > _rewriteNonMissToPerfectExpireTime) { ClearNonMissToPerfectRewrite(); } else if (_rewriteNonMissToPerfectExpireTime > 0f && Time.time <= _rewriteNonMissToPerfectExpireTime && string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId) && iBudeffPrefabController.Instance != null) { // Fallback: if icon registration failed earlier (e.g. controller initialized later), // retry during active window. float remain = Mathf.Max(0.01f, _rewriteNonMissToPerfectExpireTime - Time.time); _rewriteNonMissToPerfectIconId = iBudeffPrefabController.Instance.RegisterTimedEffect(this, PlayerBudeffIconType.ot_luckyChance, 1f, remain); iBudeffPrefabController.Instance.RefreshAllyNow(this); } } } private void Start() { // Resolve UI and optionally pull data from SO ResolveUIReferences(); ResolveHudExtras(); if (allowOverwriteFromSO) InitializeStatsFromData(); // Initialize status isDead = (maxHP <= 0); currentHP = 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 = maxHP; isDead = (maxHP <= 0); 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) // Pass skipVFX=true to avoid firing trails/hit effects on scene start EvaluateHPPercentageTriggers(currentHP, true); EvaluateAttackZeroTriggers(true); EvaluateAttackBelowTriggers(true); } // 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; if (maxMana <= 0) { manaPercentTextLegacy.text = "-/-"; 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; if (gameObject.activeInHierarchy) 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; if (gameObject.activeInHierarchy) StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot)); else Destroy(go); } 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() { bool dataFound = false; // Try TeamCharacterDataInfo first var arr = teamUIController.Instance?.GetCurrentAllySOs(); if (arr != null && slotIndex >= 0 && slotIndex < arr.Length) { var so = arr[slotIndex]; if (so != null) { dataFound = true; // 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) { dataFound = true; // 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; } } } // If no data found, set default "empty" values if (!dataFound) { maxHP = 0; currentHP = 0; maxMana = 0; currentMana = 0; if (nameText != null) nameText.text = "——"; // Ensure UI shows -/- immediately UpdateUIImmediate(); } } // 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(); // Skill-driven score changes are idol-score changes and must be reflected in ScoreManager aggregates. // This keeps right-top scoreboard/allSum_idolScore in sync with floating score popups. if (slotIndex >= 0 && slotIndex < 5 && delta != 0) { ScoreManager.Instance?.AddIdolScoreForTrack(slotIndex, delta); } 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) { SetCurrentHP(hp, animateFade, false); } public void SetCurrentHP(int hp, bool animateFade, bool skipPopup) { 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 && !skipPopup) { if (iNumberPrefabController.Instance != null) { var type = delta > 0 ? iNumberPrefabController.InstantNumberType.Heal : iNumberPrefabController.InstantNumberType.Damage; iNumberPrefabController.SpawnForAllyStatic(slotIndex, type, delta); } } 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) { ClearNonMissToPerfectRewrite(); 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) { ModifyHPRaw(delta, animateFade, false); } private void ModifyHPRaw(int delta, bool animateFade, bool skipPopup) { if (isDead && delta > 0) { // cannot heal a dead unit return; } SetCurrentHP(currentHP + delta, animateFade, skipPopup); } public void ModifyHP(int delta, bool animateFade = true) { ModifyHP(delta, animateFade, false); } public void ModifyHP(int delta, bool animateFade, bool skipPopup) { 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, skipPopup); } 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 (iNumberPrefabController.Instance != null) { var type = delta > 0 ? iNumberPrefabController.InstantNumberType.ManaPlus : iNumberPrefabController.InstantNumberType.ManaMinus; iNumberPrefabController.SpawnForAllyStatic(slotIndex, type, delta); } } 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(); if (Application.isPlaying && oldMax != maxMana && currentMana >= maxMana) { _pendingManaFullCastRetry = true; TryCastOnFullMana(); } // 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 = maxHP > 0 ? $"{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 && gameObject.activeInHierarchy) fadeHealthCoroutine = StartCoroutine(FadeHealthCoroutine(target)); else fadeHealthImage.fillAmount = target; } // HP change flash effects if (hurtRedImage != null && Application.isPlaying && gameObject.activeInHierarchy) { 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 && gameObject.activeInHierarchy) { 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 && gameObject.activeInHierarchy) fadeManaCoroutine = StartCoroutine(FadeManaCoroutine(target)); else fadeManaImage.fillAmount = target; } // Mana recovery flash effect if (flashVisuals && currentMana > oldMana && hurtRedImage != null && Application.isPlaying && gameObject.activeInHierarchy) { 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 = maxHP > 0 ? $"{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, bool deferPopup = false) { ReceiveDamageInternal(amount, source, true, deferPopup); } private void ReceiveDamageInternal(float amount, GameObject source, bool allowRedirect, bool deferPopup) { if (IsDead) return; // dead characters cannot be hit if (allowRedirect && TryGetGlobalRedirectTargetForReceiverDamage(this, out var redirector)) { ConsumeGlobalRedirect(redirector); if (redirector != null && redirector != this) { redirector.ReceiveDamageInternal(amount, source, false, deferPopup); 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 = FindAdjacentRedirectTargetForDamage(); if (adj != null && adj != this) { ConsumeSelfDamageRedirectToAdjacent(); adj.ReceiveDamageInternal(amount, source, false, deferPopup); return; } } int oldHP = currentHP; float effective = amount * (1f - damageResistance); int delta = Mathf.CeilToInt(effective); ModifyHP(-delta, true, true); int actual = oldHP - currentHP; if (actual != 0) { if (deferPopup) { QueueDamagePopup(iNumberPrefabController.InstantNumberType.Damage, -actual); } else { iNumberPrefabController.SpawnForAllyStatic(slotIndex, iNumberPrefabController.InstantNumberType.Damage, -actual); } } } public void ReceiveHeal(float amount, GameObject source, bool deferPopup = false) { if (IsDead) return; // dead characters cannot be healed int oldHP = currentHP; float mult = GetTotalHealReceivedMultiplier(); int delta = Mathf.CeilToInt(amount * mult); ModifyHP(delta, true, true); int actual = currentHP - oldHP; if (actual != 0) { if (deferPopup) { QueueDamagePopup(iNumberPrefabController.InstantNumberType.Heal, actual); } else { iNumberPrefabController.SpawnForAllyStatic(slotIndex, iNumberPrefabController.InstantNumberType.Heal, actual); } } } public void ApplyBuff(Buff buff, GameObject source) { ApplyBuffInternal(buff, source, true); } private void ApplyBuffInternal(Buff buff, GameObject source, bool allowRedirect) { if (buff == null) return; if (string.IsNullOrWhiteSpace(buff.buffId)) { buff.buffId = Guid.NewGuid().ToString(); } if (allowRedirect && TryRedirectIncomingGenericBuff(out var redirectedTarget) && redirectedTarget != null && redirectedTarget != this) { RegisterForwardedBuffTarget(buff.buffId, redirectedTarget); redirectedTarget.ApplyBuffInternal(buff, source, false); return; } activeBuffs.Add(buff); _buffAppliedTimes[buff.buffId] = Application.isPlaying ? Time.time : Time.realtimeSinceStartup; // 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); _forwardedBuffRemoveTargets.Remove(buffId); _buffAppliedTimes.Remove(buffId); return; } if (_forwardedBuffRemoveTargets.TryGetValue(buffId, out var forwarded) && forwarded != null) { _forwardedBuffRemoveTargets.Remove(buffId); forwarded.RemoveBuff(buffId); } } 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; if (SkillBuilder.Instance == null) { _pendingManaFullCastRetry = true; Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will retry when ready."); 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; _isCastingOnManaFull = true; _pendingManaGainAfterManaFullCast = 0; try { // Attempt to cast primary skill configured in SO via SkillBuilder bool anyTriggered = CastSkill(); if (anyTriggered) { 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 bool 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 false; } 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 true; } 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."); } else { SkillBuilder.Instance?.TriggerOnAdjacentAllySkillCast(slotIndex); } return anyTriggered; } 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, bool skipVFX = false) { 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, 1f, skipVFX); } 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, 1f, skipVFX); _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(bool skipVFX = false) { 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, 1f, skipVFX); } 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, 1f, skipVFX); _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(bool skipVFX = false) { 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, 1f, skipVFX); } 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, 1f, skipVFX); _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; CleanupGlobalRedirects(); float expireTime = -1f; if (duration > 0f && Application.isPlaying) { expireTime = Time.time + duration; } NextDamageRedirectState state = null; for (int i = 0; i < s_nextDamageRedirectStates.Count; i++) { var s = s_nextDamageRedirectStates[i]; if (s == null || s.redirector != redirector) continue; state = s; break; } if (state == null) { state = new NextDamageRedirectState { redirector = redirector }; s_nextDamageRedirectStates.Add(state); } else if (!string.IsNullOrEmpty(state.iconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId); state.iconId = null; } state.expireTime = expireTime; state.activationOrder = ++s_nextDamageRedirectOrderSeed; state.iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(redirector, PlayerBudeffIconType.dmg_redirect_toSelf, 0f, duration); // If adjacent allies already have buffs, transfer one now and consume this one-shot redirect. if (redirector.TryTransferExistingBuffFromAdjacentToSelf()) { ConsumeGlobalRedirect(redirector); } } private static void CleanupGlobalRedirects() { for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--) { var state = s_nextDamageRedirectStates[i]; var redirector = state != null ? state.redirector : null; bool remove = redirector == null; if (!remove && (redirector.IsDead || !redirector.gameObject.activeInHierarchy)) remove = true; if (!remove && Application.isPlaying && state.expireTime > 0f && Time.time > state.expireTime) remove = true; if (!remove) continue; if (state != null && redirector != null && !string.IsNullOrEmpty(state.iconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId); } s_nextDamageRedirectStates.RemoveAt(i); } } private static void ConsumeGlobalRedirect(AllyCombatant redirector) { if (redirector == null) return; for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--) { var state = s_nextDamageRedirectStates[i]; if (state == null) continue; if (state.redirector != redirector) continue; if (!string.IsNullOrEmpty(state.iconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId); } s_nextDamageRedirectStates.RemoveAt(i); } } private static bool AreAdjacentAllies(AllyCombatant a, AllyCombatant b) { if (a == null || b == null) return false; return Mathf.Abs(a.slotIndex - b.slotIndex) == 1; } private static bool TryGetGlobalRedirectTargetForReceiver(AllyCombatant receiver, out AllyCombatant redirector) { redirector = null; if (receiver == null) return false; CleanupGlobalRedirects(); if (s_nextDamageRedirectStates.Count == 0) return false; NextDamageRedirectState best = null; for (int i = 0; i < s_nextDamageRedirectStates.Count; i++) { var state = s_nextDamageRedirectStates[i]; if (state == null || state.redirector == null) continue; if (state.redirector == receiver) continue; if (!AreAdjacentAllies(receiver, state.redirector)) continue; if (best == null || state.activationOrder > best.activationOrder) { best = state; } } if (best == null) return false; redirector = best.redirector; return redirector != null; } private static float GetHpRatioForRedirect(AllyCombatant ally) { if (ally == null) return 0f; if (ally.maxHP <= 0) return 0f; return Mathf.Clamp01((float)ally.currentHP / ally.maxHP); } // For "交给我!" damage redirect: among eligible adjacent redirectors, // pick the one with LOWER HP ratio first. Tie-breaker: latest activation. private static bool TryGetGlobalRedirectTargetForReceiverDamage(AllyCombatant receiver, out AllyCombatant redirector) { redirector = null; if (receiver == null) return false; CleanupGlobalRedirects(); if (s_nextDamageRedirectStates.Count == 0) return false; NextDamageRedirectState best = null; float bestHpRatio = float.PositiveInfinity; const float eps = 0.0001f; for (int i = 0; i < s_nextDamageRedirectStates.Count; i++) { var state = s_nextDamageRedirectStates[i]; if (state == null || state.redirector == null) continue; if (state.redirector == receiver) continue; if (!AreAdjacentAllies(receiver, state.redirector)) continue; float hpRatio = GetHpRatioForRedirect(state.redirector); if (best == null || hpRatio < bestHpRatio - eps) { best = state; bestHpRatio = hpRatio; continue; } if (Mathf.Abs(hpRatio - bestHpRatio) <= eps && state.activationOrder > best.activationOrder) { best = state; bestHpRatio = hpRatio; } } if (best == null) return false; redirector = best.redirector; return redirector != null; } private void RegisterForwardedBuffTarget(string buffId, AllyCombatant target) { if (string.IsNullOrWhiteSpace(buffId) || target == null) return; _forwardedBuffRemoveTargets[buffId] = target; } private Buff GetTransferableExistingBuff() { Buff latest = null; float latestTime = float.NegativeInfinity; for (int i = activeBuffs.Count - 1; i >= 0; i--) { var buff = activeBuffs[i]; if (buff == null) continue; if (string.IsNullOrWhiteSpace(buff.buffId)) { buff.buffId = Guid.NewGuid().ToString(); _buffAppliedTimes[buff.buffId] = Application.isPlaying ? Time.time : Time.realtimeSinceStartup; } float t = 0f; if (!_buffAppliedTimes.TryGetValue(buff.buffId, out t)) { t = Application.isPlaying ? Time.time : Time.realtimeSinceStartup; _buffAppliedTimes[buff.buffId] = t; } if (latest == null || t > latestTime) { latest = buff; latestTime = t; } } return latest; } private void RemoveLocalBuffInstance(Buff buff) { if (buff == null) return; if (!activeBuffs.Remove(buff)) return; if (buff.scoreMultiplier != 1f) scoreEfficiency /= buff.scoreMultiplier; if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs(); if (!string.IsNullOrWhiteSpace(buff.buffId)) { _forwardedBuffRemoveTargets.Remove(buff.buffId); _buffAppliedTimes.Remove(buff.buffId); } iBudeffPrefabController.Instance?.NotifyBuffRemoved(this, buff); } private bool TransferOneExistingBuffTo(AllyCombatant target) { if (target == null || target == this) return false; if (IsDead || target.IsDead) return false; if (!target.gameObject.activeInHierarchy) return false; var objectBuff = GetTransferableExistingBuff(); bool hasObjectBuff = objectBuff != null; float objectBuffTime = float.NegativeInfinity; if (hasObjectBuff && !string.IsNullOrWhiteSpace(objectBuff.buffId)) { if (!_buffAppliedTimes.TryGetValue(objectBuff.buffId, out objectBuffTime)) { objectBuffTime = Application.isPlaying ? Time.time : Time.realtimeSinceStartup; _buffAppliedTimes[objectBuff.buffId] = objectBuffTime; } } float budeffTime = float.NegativeInfinity; bool hasBudeff = iBudeffPrefabController.Instance != null && iBudeffPrefabController.Instance.TryGetLatestAllyBuffTimestamp(this, out budeffTime); if (!hasObjectBuff && !hasBudeff) return false; if (hasObjectBuff && (!hasBudeff || objectBuffTime >= budeffTime)) { return TryTransferExistingBuffObjectTo(target, objectBuff); } return TryTransferOneExistingBudeffTo(target); } private int GetBuffCountForRedirectSelection() { var ctrl = iBudeffPrefabController.Instance; if (ctrl != null && ctrl.TryGetTransferableAllyBuffGroupCount(this, out int groupCount)) { // If there are visible transferable budeff groups, use that as the primary count. if (groupCount > 0) return groupCount; } // Fallback for non-budeff object buffs. return activeBuffs != null ? activeBuffs.Count : 0; } private AllyCombatant FindAdjacentAllyByBuffCount(bool pickMost, bool requireAtLeastOneBuff) { AllyCombatant best = null; int bestCount = pickMost ? int.MinValue : int.MaxValue; System.Action eval = (ally) => { if (ally == null || ally == this) return; if (ally.IsDead || !ally.gameObject.activeInHierarchy) return; int count = ally.GetBuffCountForRedirectSelection(); if (requireAtLeastOneBuff && count <= 0) return; if (best == null) { best = ally; bestCount = count; return; } if (pickMost) { if (count > bestCount) { best = ally; bestCount = count; } } else { if (count < bestCount) { best = ally; bestCount = count; } } }; 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); eval(ally); } return best; } 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; eval(ally); } if (right <= 4) { var go = GameObject.Find($"ally_0{right + 1}"); var ally = go != null ? (go.GetComponent() ?? go.GetComponentInChildren(true)) : null; eval(ally); } return best; } private AllyCombatant FindAdjacentAllyByHealth(bool pickHigherHp) { AllyCombatant best = null; float bestRatio = pickHigherHp ? float.NegativeInfinity : float.PositiveInfinity; int bestHp = pickHigherHp ? int.MinValue : int.MaxValue; const float eps = 0.0001f; System.Action eval = (ally) => { if (ally == null || ally == this) return; if (ally.IsDead || !ally.gameObject.activeInHierarchy) return; float ratio = GetHpRatioForRedirect(ally); int hp = ally.currentHP; if (best == null) { best = ally; bestRatio = ratio; bestHp = hp; return; } if (pickHigherHp) { if (ratio > bestRatio + eps || (Mathf.Abs(ratio - bestRatio) <= eps && hp > bestHp)) { best = ally; bestRatio = ratio; bestHp = hp; } } else { if (ratio < bestRatio - eps || (Mathf.Abs(ratio - bestRatio) <= eps && hp < bestHp)) { best = ally; bestRatio = ratio; bestHp = hp; } } }; 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); eval(ally); } return best; } 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; eval(ally); } if (right <= 4) { var go = GameObject.Find($"ally_0{right + 1}"); var ally = go != null ? (go.GetComponent() ?? go.GetComponentInChildren(true)) : null; eval(ally); } return best; } private bool TryTransferExistingBuffObjectTo(AllyCombatant target, Buff buff = null) { if (target == null) return false; if (buff == null) buff = GetTransferableExistingBuff(); if (buff == null) return false; var actualTarget = ResolveRedirectTargetForIncomingBuff(target); if (actualTarget == null || actualTarget == this) return false; RemoveLocalBuffInstance(buff); RegisterForwardedBuffTarget(buff.buffId, actualTarget); actualTarget.ApplyBuffInternal(buff, this.gameObject, false); return true; } private bool TryTransferOneExistingBudeffTo(AllyCombatant target) { var ctrl = iBudeffPrefabController.Instance; if (ctrl == null) return false; var actualTarget = ResolveRedirectTargetForIncomingBuff(target); if (actualTarget == null || actualTarget == this) return false; if (!ctrl.TryTransferLatestAllyBuff(this, actualTarget, out var iconType, out var value, out var startTime, out var duration)) return false; ApplyTransferredBudeffEffect(actualTarget, iconType, value); ctrl.RefreshAllyNow(this); ctrl.RefreshAllyNow(actualTarget); // Timed direct-effect buffs usually still have a revert coroutine bound to the original target. // Compensate at expiry: source +delta, target -delta, to keep net transfer behavior correct. if (duration > 0f) { float remaining = Mathf.Max(0.01f, (startTime + duration) - Time.time); StartCoroutine(CompensateTransferredTimedBudeffAtExpiry(actualTarget, iconType, value, remaining)); } return true; } private IEnumerator CompensateTransferredTimedBudeffAtExpiry(AllyCombatant target, PlayerBudeffIconType iconType, float value, float waitSeconds) { yield return new WaitForSeconds(Mathf.Max(0.01f, waitSeconds)); if (target == null) yield break; if (this == null) yield break; // Inverse move to neutralize original source-side timed revert and end target-side buff. ApplyTransferredBudeffEffect(target, iconType, -value); var ctrl = iBudeffPrefabController.Instance; if (ctrl != null) { ctrl.RefreshAllyNow(this); ctrl.RefreshAllyNow(target); } } private void ApplyTransferredBudeffEffect(AllyCombatant target, PlayerBudeffIconType iconType, float value) { if (target == null) return; switch (iconType) { case PlayerBudeffIconType.ot_maxHP_up: case PlayerBudeffIconType.ot_maxHP_down: { int delta = Mathf.RoundToInt(value); if (delta != 0) { ApplyMaxHPDelta(this, -delta); ApplyMaxHPDelta(target, delta); } } break; case PlayerBudeffIconType.ot_maxMana_up: case PlayerBudeffIconType.ot_maxMana_down: { int delta = Mathf.RoundToInt(value); if (delta != 0) { ApplyMaxManaDelta(this, -delta); ApplyMaxManaDelta(target, delta); } } break; case PlayerBudeffIconType.ot_scoreEfficiency_up: case PlayerBudeffIconType.ot_scoreEfficiency_down: if (!Mathf.Approximately(value, 0f)) { scoreEfficiency = Mathf.Max(0f, scoreEfficiency - value); target.scoreEfficiency = Mathf.Max(0f, target.scoreEfficiency + value); } break; case PlayerBudeffIconType.ot_atk_up: case PlayerBudeffIconType.ot_atk_down: { int delta = Mathf.RoundToInt(value); if (delta != 0) { ModifyAttack(-delta); target.ModifyAttack(delta); } } break; case PlayerBudeffIconType.ot_defend_up: case PlayerBudeffIconType.ot_defend_down: case PlayerBudeffIconType.ot_vulnerability: if (!Mathf.Approximately(value, 0f)) { damageResistance = Mathf.Min(damageResistance - value, 1f); target.damageResistance = Mathf.Min(target.damageResistance + value, 1f); } break; case PlayerBudeffIconType.dmg_redirect_toSelf: TransferRedirectToSelfStateTo(target); break; case PlayerBudeffIconType.dmg_redirect_toAdjacent: TransferRedirectToAdjacentStateTo(target); break; } } private AllyCombatant ResolveRedirectTargetForIncomingBuff(AllyCombatant intendedTarget) { if (intendedTarget == null) return null; if (intendedTarget.IsDead || !intendedTarget.gameObject.activeInHierarchy) return intendedTarget; if (intendedTarget.TryRedirectIncomingGenericBuff(out var redirected) && redirected != null) { return redirected; } return intendedTarget; } private void TransferRedirectToSelfStateTo(AllyCombatant target) { if (target == null || target == this) return; if (target.IsDead || !target.gameObject.activeInHierarchy) return; CleanupGlobalRedirects(); // Keep single state per redirector target to avoid duplicate one-shot slots. ConsumeGlobalRedirect(target); NextDamageRedirectState state = null; for (int i = 0; i < s_nextDamageRedirectStates.Count; i++) { var s = s_nextDamageRedirectStates[i]; if (s == null || s.redirector != this) continue; if (state == null || s.activationOrder > state.activationOrder) state = s; } if (state == null) return; state.redirector = target; state.activationOrder = ++s_nextDamageRedirectOrderSeed; } private void TransferRedirectToAdjacentStateTo(AllyCombatant target) { if (target == null || target == this) return; if (target.IsDead || !target.gameObject.activeInHierarchy) return; if (!IsSelfDamageRedirectToAdjacentActive()) return; float remain = Mathf.Max(0.01f, _selfDamageRedirectToAdjacentExpireTime - Time.time); string iconId = _selfDamageRedirectToAdjacentIconId; // Clear source state without touching icon (icon already moved by budeff controller transfer). _selfDamageRedirectToAdjacentExpireTime = -1f; _selfDamageRedirectToAdjacentIconId = null; // Keep only one self-redirect state on target. target.ConsumeSelfDamageRedirectToAdjacent(); target._selfDamageRedirectToAdjacentExpireTime = Time.time + remain; target._selfDamageRedirectToAdjacentIconId = iconId; } private static void ApplyMaxHPDelta(AllyCombatant ally, int delta) { if (ally == null || delta == 0) return; if (delta > 0) { ally.SetMaxHP(Mathf.Max(1, ally.maxHP + delta), false); ally.SetCurrentHP(ally.currentHP + delta, false); } else { ally.SetMaxHP(Mathf.Max(1, ally.maxHP + delta), false); } } private static void ApplyMaxManaDelta(AllyCombatant ally, int delta) { if (ally == null || delta == 0) return; ally.SetMaxMana(Mathf.Max(1, ally.maxMana + delta), false); } private bool TryPeekLatestTransferableBuffTime(out float latestTime) { latestTime = float.NegativeInfinity; bool found = false; for (int i = 0; i < activeBuffs.Count; i++) { var buff = activeBuffs[i]; if (buff == null || string.IsNullOrWhiteSpace(buff.buffId)) continue; if (!_buffAppliedTimes.TryGetValue(buff.buffId, out float t)) continue; if (!found || t > latestTime) { latestTime = t; found = true; } } var ctrl = iBudeffPrefabController.Instance; if (ctrl != null && ctrl.TryGetLatestAllyBuffTimestamp(this, out float buffTime)) { if (!found || buffTime > latestTime) { latestTime = buffTime; found = true; } } return found; } private bool TryTransferExistingBuffFromAdjacentToSelf() { // "交给我!" rule: pick adjacent ally with the FEWEST buffs, then take one buff. var donor = FindAdjacentAllyByBuffCount(pickMost: false, requireAtLeastOneBuff: true); return donor != null && donor.TransferOneExistingBuffTo(this); } // For direct buff-like effects that do not go through ApplyBuff(Buff,...): // resolve redirect chain and consume one-shot redirect states along the path. public bool TryRedirectIncomingGenericBuff(out AllyCombatant redirectedTarget) { redirectedTarget = this; if (IsDead) return false; AllyCombatant current = this; bool moved = false; const int maxHop = 6; int hop = 0; var visited = new HashSet { current.GetInstanceID() }; while (hop < maxHop && current != null) { bool movedThisRound = false; // 1) Current receiver's "都给你!" redirects this incoming buff outward first. if (current.IsSelfDamageRedirectToAdjacentActive()) { var adj = current.FindAdjacentRedirectTarget(); if (adj != null && adj != current && !adj.IsDead && adj.gameObject.activeInHierarchy) { current.ConsumeSelfDamageRedirectToAdjacent(); current = adj; moved = true; movedThisRound = true; hop++; if (!visited.Add(current.GetInstanceID())) { // Break redirect loops (A->B->A...). break; } } } if (hop >= maxHop || current == null) break; // 2) Then check new receiver's adjacent "交给我!" and pull inward. if (TryGetGlobalRedirectTargetForReceiver(current, out var redirector)) { ConsumeGlobalRedirect(redirector); if (redirector != null && redirector != current && !redirector.IsDead && redirector.gameObject.activeInHierarchy) { current = redirector; moved = true; movedThisRound = true; hop++; if (!visited.Add(current.GetInstanceID())) { break; } } } if (!movedThisRound) break; } redirectedTarget = current ?? this; return moved && redirectedTarget != this; } 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); // "都给你!" rule: transfer to adjacent ally with the MOST buffs. var adj = FindAdjacentAllyByBuffCount(pickMost: true, requireAtLeastOneBuff: false); if (adj != null && TransferOneExistingBuffTo(adj)) { ConsumeSelfDamageRedirectToAdjacent(); } } public void ActivateNonMissToPerfectRewrite(float duration) { if (!Application.isPlaying) return; if (duration <= 0f) duration = 0.01f; _rewriteNonMissToPerfectExpireTime = Time.time + duration; if (!string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _rewriteNonMissToPerfectIconId); _rewriteNonMissToPerfectIconId = null; } _rewriteNonMissToPerfectIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(this, PlayerBudeffIconType.ot_luckyChance, 1f, duration); iBudeffPrefabController.Instance?.RefreshAllyNow(this); } public bool IsNonMissToPerfectRewriteActive() { if (!Application.isPlaying) return false; if (_rewriteNonMissToPerfectExpireTime <= 0f) return false; if (Time.time > _rewriteNonMissToPerfectExpireTime) { ClearNonMissToPerfectRewrite(); return false; } if (IsDead) { ClearNonMissToPerfectRewrite(); return false; } return true; } private void ClearNonMissToPerfectRewrite() { _rewriteNonMissToPerfectExpireTime = -1f; if (!string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _rewriteNonMissToPerfectIconId); _rewriteNonMissToPerfectIconId = null; } iBudeffPrefabController.Instance?.RefreshAllyNow(this); } public bool HasNonMissToPerfectRewriteForUI() { if (!Application.isPlaying) return false; if (IsDead) return false; return _rewriteNonMissToPerfectExpireTime > 0f && Time.time <= _rewriteNonMissToPerfectExpireTime; } private bool IsSelfDamageRedirectToAdjacentActive() { if (!Application.isPlaying) return false; if (_selfDamageRedirectToAdjacentExpireTime <= 0f) return false; if (IsDead) { ConsumeSelfDamageRedirectToAdjacent(); return false; } if (Time.time > _selfDamageRedirectToAdjacentExpireTime) { ConsumeSelfDamageRedirectToAdjacent(); return false; } return true; } private void ConsumeSelfDamageRedirectToAdjacent() { _selfDamageRedirectToAdjacentExpireTime = -1f; if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId)) { iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId); _selfDamageRedirectToAdjacentIconId = null; } } private AllyCombatant FindAdjacentRedirectTarget() { // For "都给你!", route to adjacent ally with the MOST buffs. return FindAdjacentAllyByBuffCount(pickMost: true, requireAtLeastOneBuff: false); } private AllyCombatant FindAdjacentRedirectTargetForDamage() { // For "都给你!" damage redirect: higher HP adjacent ally is preferred. return FindAdjacentAllyByHealth(pickHigherHp: true); } 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; } } }