Files
bansonic_beta_main/Assets/scripts/Combat/AllyCombatant.cs
T
2026-07-30 23:15:58 +08:00

3516 lines
132 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
[ExecuteAlways]
/// <summary>
/// Documentation text normalized.
/// Documentation text normalized.
/// Documentation text normalized.
/// Documentation text normalized.
/// Documentation text normalized.
/// </summary>
public class AllyCombatant : MonoBehaviour, ICombatant
{
private static float GameplayNow => Application.isPlaying ? GameplayClock.NowSongTime : Time.realtimeSinceStartup;
private static AllyHero_SO[] cachedAllAllyHeroes;
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<NextDamageRedirectState> s_nextDamageRedirectStates = new List<NextDamageRedirectState>(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;
// True (permanent) attack baseline. Only permanent changes touch this: initial stats,
// level-ups, LevelRule AttackDelta, and duration<=0 skills. Timed skill buffs must NOT
// bake into this — they live in _attackModifiers so percentages stay computed off the
// unbuffed base and re-triggering a timed buff refreshes instead of compounding.
private int _attackBaseUnbuffed = 0;
private bool _attackBaseInitialized = false;
// Additive attack modifiers keyed by source (skillId). Each source contributes exactly one
// entry; re-applying the same source replaces its value (idempotent), so "+10% from A" and
// "+20% from B" sum to +30% of base rather than compounding multiplicatively per note hit.
private readonly Dictionary<string, int> _attackModifiers = new Dictionary<string, int>();
// 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;
private Text legacyNameText;
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<Buff> activeBuffs = new List<Buff>();
// original receiver -> actual redirected owner by buffId, used for later RemoveBuff routing.
private readonly Dictionary<string, AllyCombatant> _forwardedBuffRemoveTargets = new Dictionary<string, AllyCombatant>();
private readonly Dictionary<string, float> _buffAppliedTimes = new Dictionary<string, float>();
private Coroutine skillIconEnterCoroutine;
private Image skillIconEnterAnimatingImage;
private Coroutine skillIconAutoFadeCoroutine;
private float lastSkillIconPushTime = -1f;
// Pool of removed-skill-icon GameObjects reused by PlaySkillIconExitAnim, so each icon
// eviction reuses an instance instead of new GameObject(...) + Destroy every time.
private readonly Stack<GameObject> _removedSkillIconPool = new Stack<GameObject>();
private readonly List<GameObject> _removedSkillIconAll = new List<GameObject>();
// Track skills that are currently active because HP percent condition is satisfied.
private HashSet<string> _activeHpPercentSkills = new HashSet<string>();
// Track applied reversible effects for HP-percent skills so we can revert them on leave
private Dictionary<string, AppliedEffect> _appliedHpPercentEffects = new Dictionary<string, AppliedEffect>();
// Track skills that are currently active because Attack == 0
private HashSet<string> _activeAttackZeroSkills = new HashSet<string>();
private Dictionary<string, AppliedEffect> _appliedAttackZeroEffects = new Dictionary<string, AppliedEffect>();
// Track skills that are currently active because Attack is below a threshold (OnAttackBelowValue)
private HashSet<string> _activeAttackBelowSkills = new HashSet<string>();
private Dictionary<string, AppliedEffect> _appliedAttackBelowEffects = new Dictionary<string, AppliedEffect>();
// Track skills that are currently active because Attack is above a threshold (OnAttackAboveValue)
private HashSet<string> _activeAttackAboveSkills = new HashSet<string>();
private Dictionary<string, AppliedEffect> _appliedAttackAboveEffects = new Dictionary<string, AppliedEffect>();
// 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;
// Last event payloads used by custom equipment skill handlers.
public int lastHealOverflowAmount { get; private set; }
public int lastHealActualAmount { get; private set; }
public int lastDamageTakenAmount { get; private set; }
public int lastAdjacentAllyDamageTakenAmount { get; private set; }
public int lastMaxHPIncreaseAmount { get; private set; }
public int lastManaSpentAmount { get; private set; }
public bool lastManaSpendWasFullBar { get; private set; }
// Reusable buffers to reduce allocations during skill evaluation
private readonly List<SkillDefinition> _tmpSkillDefs = new List<SkillDefinition>(16);
private readonly Dictionary<string, float> _formulaVarsBuffer = new Dictionary<string, float>(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<string>();
if (_appliedHpPercentEffects == null) _appliedHpPercentEffects = new Dictionary<string, AppliedEffect>();
if (_activeAttackZeroSkills == null) _activeAttackZeroSkills = new HashSet<string>();
if (_appliedAttackZeroEffects == null) _appliedAttackZeroEffects = new Dictionary<string, AppliedEffect>();
if (_activeAttackBelowSkills == null) _activeAttackBelowSkills = new HashSet<string>();
if (_appliedAttackBelowEffects == null) _appliedAttackBelowEffects = new Dictionary<string, AppliedEffect>();
if (_activeAttackAboveSkills == null) _activeAttackAboveSkills = new HashSet<string>();
if (_appliedAttackAboveEffects == null) _appliedAttackAboveEffects = new Dictionary<string, AppliedEffect>();
}
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 && GameplayNow - 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 && GameplayNow > _selfDamageRedirectToAdjacentExpireTime)
{
ConsumeSelfDamageRedirectToAdjacent();
}
if (_rewriteNonMissToPerfectExpireTime > 0f && GameplayNow > _rewriteNonMissToPerfectExpireTime)
{
ClearNonMissToPerfectRewrite();
}
else if (_rewriteNonMissToPerfectExpireTime > 0f &&
GameplayNow <= _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 - GameplayNow);
_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()
{
// Refresh identity/stats as soon as the gameplay UI controller is ready.
// Do not block this on SkillBuilder, otherwise early-starting slots can stay
// on the empty placeholder state until much later.
while (teamUIController.Instance == null)
{
yield return null;
}
// Wait a couple of frames so teamUIController.Start() can finish loading slot ids/SOs.
yield return null;
yield return null;
ResolveUIReferences();
// Re-run initialization to ensure we have the correct identity/stats from the loaded team.
if (allowOverwriteFromSO)
{
InitializeStatsFromData();
currentHP = maxHP;
isDead = (maxHP <= 0);
UpdateUIImmediate();
}
iBudeffPrefabController.Instance?.RegisterBaseline(this);
while (SkillBuilder.Instance == null)
{
yield return null;
}
// 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);
EvaluateAttackAboveTriggers(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();
EvaluateAttackAboveTriggers();
TryCastOnFullMana();
}
}
private void ResolveUIReferences()
{
// Try singleton first, fallback to scene search (works in editor)
var ui = teamUIController.Instance != null ? teamUIController.Instance : SceneObjectLookupCache.FindAny<teamUIController>();
if (ui == null) return;
GameObject parent = null;
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;
parent = ui.objectFather_ally01;
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;
parent = ui.objectFather_ally02;
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;
parent = ui.objectFather_ally03;
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;
parent = ui.objectFather_ally04;
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;
parent = ui.objectFather_ally05;
break;
}
legacyNameText = ResolveLegacyNameText(parent);
// If the assigned TMP field is null, attempt to resolve from the UI parent's object (teamUIController keeps objectFather references)
if (currentScoreText == null)
{
GameObject scoreParent = null;
switch (slotIndex)
{
case 0: scoreParent = ui.objectFather_ally01; break;
case 1: scoreParent = ui.objectFather_ally02; break;
case 2: scoreParent = ui.objectFather_ally03; break;
case 3: scoreParent = ui.objectFather_ally04; break;
case 4: scoreParent = ui.objectFather_ally05; break;
}
if (scoreParent != null)
{
var tmp = scoreParent.GetComponentInChildren<TextMeshProUGUI>(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 {scoreParent.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 = scoreParent.GetComponentsInChildren<Text>(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<TextMeshProUGUI>() ?? go.AddComponent<TextMeshProUGUI>();
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 {scoreParent.name}. Added/used TMP component on {go.name} and copied text.");
}
}
}
}
ResolveHudExtras();
}
private Text ResolveLegacyNameText(GameObject parent)
{
if (parent == null) return null;
var allLegacy = parent.GetComponentsInChildren<Text>(true);
for (int i = 0; i < allLegacy.Length; i++)
{
var candidate = allLegacy[i];
if (candidate == null || candidate.gameObject == null) continue;
if (candidate.gameObject.name == "name")
return candidate;
}
return null;
}
private void SetDisplayName(string value)
{
string finalValue = value ?? string.Empty;
if (nameText != null) nameText.text = finalValue;
if (legacyNameText != null) legacyNameText.text = finalValue;
}
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<Text>();
}
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<Image>(8);
for (int i = 0; i < t.childCount; i++)
{
var child = t.GetChild(i);
if (child == null) continue;
var img = child.GetComponent<Image>();
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() + "%";
}
/// <summary>
/// Push a skill icon into this ally's "skill_img" UI group (newest first, max 3).
/// </summary>
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 = GameplayNow;
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 GameplayClock.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 = AcquireRemovedSkillIcon();
RectTransform hostRt = hostSlot.rectTransform;
RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null;
go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false);
go.SetActive(true);
var rt = go.GetComponent<RectTransform>();
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<Image>();
img.raycastTarget = false;
img.sprite = removedSprite;
img.color = removedColor;
if (gameObject.activeInHierarchy)
StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot));
else
ReleaseRemovedSkillIcon(go);
}
private GameObject AcquireRemovedSkillIcon()
{
GameObject go = null;
while (_removedSkillIconPool.Count > 0)
{
go = _removedSkillIconPool.Pop();
if (go != null) break;
go = null;
}
if (go == null)
{
go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
_removedSkillIconAll.Add(go);
}
return go;
}
private void ReleaseRemovedSkillIcon(GameObject go)
{
if (go == null) return;
go.SetActive(false);
go.transform.SetParent(transform, false);
_removedSkillIconPool.Push(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;
}
ReleaseRemovedSkillIcon(go);
}
private void InitializeStatsFromData()
{
bool dataFound = false;
var uiController = teamUIController.Instance;
int resolvedHeroId = ResolveAssignedHeroId();
if (uiController != null && resolvedHeroId > 0)
{
var preload = uiController.GetCurrentAllySOs();
bool needsPopulate = preload == null || slotIndex < 0 || slotIndex >= preload.Length || preload[slotIndex] == null;
if (needsPopulate)
{
uiController.PopulateAllySOsFromIds();
}
}
// Try TeamCharacterDataInfo first
var arr = uiController?.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))
{
SetDisplayName(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 = resolvedHeroId;
if (id > 0)
{
EnsureAllyHeroCache();
foreach (var a in cachedAllAllyHeroes)
{
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)
{
var resolved = a.GetEffectiveLevelInfoWithEquipment(eff);
maxHP = resolved.maxHP;
maxMana = resolved.maxMana;
damageResistance = resolved.damageResistance;
scoreEfficiency = resolved.scoreEfficiency;
SetAttack(resolved.attack);
}
else
{
var lvl = a.levelStats[0];
var resolved = a.GetEffectiveLevelInfoWithEquipment(lvl);
maxHP = resolved.maxHP;
maxMana = resolved.maxMana;
damageResistance = resolved.damageResistance;
scoreEfficiency = resolved.scoreEfficiency;
SetAttack(resolved.attack);
}
}
SetDisplayName(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;
SetDisplayName("——");
// Ensure UI shows -/- immediately
UpdateUIImmediate();
}
}
private int ResolveAssignedHeroId()
{
var ui = teamUIController.Instance;
if (ui != null && ui.allySlotIds != null && slotIndex >= 0 && slotIndex < ui.allySlotIds.Count)
{
int assigned = ui.allySlotIds[slotIndex];
if (assigned > 0) return assigned;
}
int prefsSlot = Mathf.Clamp(slotIndex + 1, 1, 5);
int fallback = PlayerPrefs.GetInt($"selected_heroSlot0{prefsSlot}_heroID", 0);
if (fallback > 0 && ui != null)
{
if (ui.allySlotIds == null || ui.allySlotIds.Count < 5)
{
ui.allySlotIds = new List<int> { 0, 0, 0, 0, 0 };
}
if (slotIndex >= 0 && slotIndex < ui.allySlotIds.Count)
{
ui.allySlotIds[slotIndex] = fallback;
}
}
return fallback;
}
private static void EnsureAllyHeroCache()
{
if (cachedAllAllyHeroes != null) return;
cachedAllAllyHeroes = RuntimeResourcesCache.LoadAllAllyHeroes() ?? Array.Empty<AllyHero_SO>();
}
// 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;
List<AllyHero_SO.AllyLevelInfo> sorted = new List<AllyHero_SO.AllyLevelInfo>();
foreach (var levelInfo in so.levelStats)
{
if (levelInfo != null)
{
sorted.Add(levelInfo);
}
}
if (sorted.Count == 0) return null;
sorted.Sort((left, right) => left.requiredEXP.CompareTo(right.requiredEXP));
int expQualifiedIndex = 0;
int safeExp = Mathf.Max(0, exp);
for (int i = 0; i < sorted.Count; i++)
{
if (safeExp >= Mathf.Max(0, sorted[i].requiredEXP))
{
expQualifiedIndex = i;
}
else
{
break;
}
}
int unlockedIndex = Mathf.Clamp(so.ally_growthUnlockedTierIndex, 0, sorted.Count - 1);
int effectiveIndex = Mathf.Min(expQualifiedIndex, unlockedIndex);
return sorted[Mathf.Clamp(effectiveIndex, 0, sorted.Count - 1)];
}
// 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();
}
// 性能:记录上次写入的 "cur/max" 字符串及其目标 Text 对象。仅当数值或目标对象变化时才写 .text,
// 避免每次判定对同一 Text 重复赋相同字符串触发 TMP 网格重建。目标对象变化(换局/UI 重建)必写。
private string _lastScoreUiText;
private UnityEngine.Object _lastScoreUiTarget;
private void UpdateScoreUI()
{
string value = $"{currentScore}/{maxTrackScore}";
if (currentScoreText != null)
{
if (!ReferenceEquals(_lastScoreUiTarget, currentScoreText) || _lastScoreUiText != value)
{
currentScoreText.text = value;
_lastScoreUiTarget = currentScoreText;
_lastScoreUiText = 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)
{
// 性能:fallback 走固定的 teammateXX 字段(slotIndex 固定,目标稳定),值未变则跳过整段写入(等效)。
if (_lastScoreUiText == value) return;
_lastScoreUiText = value;
_lastScoreUiTarget = 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 && teamUIController.Instance != null)
{
if (delta > 0)
{
teamUIController.Instance.RecordHeal(slotIndex, delta);
}
else
{
teamUIController.Instance.RecordDamageTaken(slotIndex, -delta);
}
}
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();
lastMaxHPIncreaseAmount = Mathf.Max(0, maxHP - oldMax);
if (lastMaxHPIncreaseAmount > 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnMaxHPIncreased);
}
// 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;
int requestedMana = mana;
bool incomingOverflowGainAtFull = mana > maxMana && old >= maxMana;
currentMana = Mathf.Clamp(mana, 0, maxMana);
UpdateManaVisuals(old, animateFade, flashVisuals);
// Only auto-cast when we actually reach full mana from below.
// Also handle positive mana input while already full (e.g. maxMana=1 and receiving mana from adjacent skills):
// without this, mana can stay at 100% with no further "reach full from below" transition.
if ((old < maxMana && currentMana >= maxMana) || incomingOverflowGainAtFull)
{
TryCastOnFullMana();
}
// Trigger mana changed events
int delta = currentMana - old;
if (delta > 0 && teamUIController.Instance != null)
{
teamUIController.Instance.RecordMana(slotIndex, delta);
}
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)
{
lastManaSpentAmount = -delta;
lastManaSpendWasFullBar = old >= maxMana && currentMana <= 0;
if (_isTriggeringManaLost) return;
_isTriggeringManaLost = true;
try
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaLost);
}
finally
{
_isTriggeringManaLost = false;
}
}
else
{
lastManaSpentAmount = 0;
lastManaSpendWasFullBar = false;
}
int finalMana = currentMana;
if (requestedMana != old || incomingOverflowGainAtFull)
{
string manaEventType;
if (incomingOverflowGainAtFull && finalMana == old && old >= maxMana)
{
manaEventType = "ManaOverflowAtFull";
GameplaySkillLogger.RecordConflictHint(
"AllyMana",
"OverflowGainAtFull",
"ally=" + allyName + " | slot=" + slotIndex + " | requested=" + requestedMana + " | current=" + finalMana + " | max=" + maxMana);
}
else if (finalMana > old)
{
manaEventType = "ManaGain";
}
else if (finalMana < old)
{
manaEventType = "ManaSpend";
}
else
{
manaEventType = "ManaClampNoDelta";
}
GameplaySkillLogger.RecordAllyResourceEvent(
allyName,
slotIndex,
"Mana",
manaEventType,
requestedMana - old,
finalMana - old,
old,
finalMana,
maxMana,
"SetCurrentMana");
}
}
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 modifierSum = 0;
foreach (var kv in _attackModifiers) modifierSum += kv.Value;
int old = attack;
// Percentage buffs are pre-summed into _attackModifiers off the base, so the final value is
// (base + Σ sourceModifiers) with the legacy multiplicative Buff path (mult, normally 1) on top.
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum);
if (old != attack)
{
EvaluateAttackZeroTriggers();
EvaluateAttackBelowTriggers();
EvaluateAttackAboveTriggers();
}
}
// Permanent base attack, unaffected by timed buffs. Skills that compute a percentage of
// attack should read this so the percentage is always relative to the unbuffed value.
public int GetBaseAttack()
{
EnsureAttackBaseInitialized();
return _attackBaseUnbuffed;
}
// Set/clear an additive attack modifier contributed by a single source (keyed by skillId).
// Re-applying the same source replaces its prior contribution instead of stacking, which is
// what keeps repeated OnNoteHit percentage buffs from compounding every note.
public void SetAttackModifier(string sourceKey, int delta)
{
if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; }
EnsureAttackBaseInitialized();
if (delta == 0) _attackModifiers.Remove(sourceKey);
else _attackModifiers[sourceKey] = delta;
RecalculateAttackFromBuffs();
}
public void ClearAttackModifier(string sourceKey)
{
if (string.IsNullOrEmpty(sourceKey)) return;
if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs();
}
// Permanent base change (level stats, LevelRule AttackDelta, duration<=0 skills).
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);
if (SkillBuilder.Instance != null && SkillBuilder.Instance.TryPreventLethalDamage(this, delta))
{
return;
}
ModifyHP(-delta, true, true);
int actual = oldHP - currentHP;
lastDamageTakenAmount = actual;
GameplaySkillLogger.RecordAllyResourceEvent(
allyName,
slotIndex,
"HP",
"DamageTaken",
amount,
effective,
oldHP,
currentHP,
maxHP,
source != null ? source.name : "None");
if (actual != 0)
{
if (deferPopup)
{
QueueDamagePopup(iNumberPrefabController.InstantNumberType.Damage, -actual);
}
else
{
iNumberPrefabController.SpawnForAllyStatic(slotIndex, iNumberPrefabController.InstantNumberType.Damage, -actual);
}
}
if (actual > 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnDamageTaken);
NotifyAdjacentAlliesDamaged(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);
int rawTargetHp = oldHP + delta;
ModifyHP(delta, true, true);
int actual = currentHP - oldHP;
lastHealActualAmount = actual;
lastHealOverflowAmount = Mathf.Max(0, rawTargetHp - maxHP);
GameplaySkillLogger.RecordAllyResourceEvent(
allyName,
slotIndex,
"HP",
"HealTaken",
amount,
amount * mult,
oldHP,
currentHP,
maxHP,
source != null ? source.name : "None");
if (actual != 0)
{
if (deferPopup)
{
QueueDamagePopup(iNumberPrefabController.InstantNumberType.Heal, actual);
}
else
{
iNumberPrefabController.SpawnForAllyStatic(slotIndex, iNumberPrefabController.InstantNumberType.Heal, actual);
}
}
}
private void NotifyAdjacentAlliesDamaged(int actualDamage)
{
if (actualDamage <= 0) return;
var ui = teamUIController.Instance;
if (ui == null) return;
int[] adjacent = ui.GetAdjacentAllyIndices(slotIndex);
if (adjacent == null || adjacent.Length == 0) return;
for (int i = 0; i < adjacent.Length; i++)
{
var allyObj = ui.GetAllyObjectBySlot(adjacent[i]);
if (allyObj == null) continue;
var ally = allyObj.GetComponent<AllyCombatant>();
if (ally == null || ally == this || ally.IsDead) continue;
ally.lastAdjacentAllyDamageTakenAmount = actualDamage;
ally.TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnAdjacentAllyDamaged);
}
}
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] = GameplayNow;
// 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);
SkillBuilder.Instance?.NotifyBuffApplied(slotIndex);
}
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
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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;
}
}
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)
{
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1}: AllyHero_SO not found for slot. Skip OnManaFull cast.");
return false;
}
bool anyTriggered = false;
// First: equipped groups
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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;
}
}
}
if (!anyTriggered)
{
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1}: no skills configured for OnManaFull.");
}
else
{
SkillBuilder.Instance?.TriggerOnAdjacentAllySkillCast(slotIndex);
GameplayLevelRuleEventBus.NotifyAllySkillCast(slotIndex, GameplayClock.NowSongTime);
}
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<string, float> 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;
vars["noteBaseScore"] = bmm != null ? Mathf.Max(0, bmm.perNoteScore) : Mathf.Max(0, baseTrackScore);
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();
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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);
}
}
}
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();
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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);
}
}
}
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();
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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);
}
}
}
// 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);
}
}
}
}
private void EvaluateAttackAboveTriggers(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();
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
{
foreach (int gid in runtimeGroupIds)
{
if (gid == 0) continue;
SkillGroup group = so.GetSkillGroupByID(gid);
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);
}
}
}
// Pick the closest satisfied threshold from below (largest attackTriggerValue such that attack > value).
string selected = null;
int selectedThreshold = int.MinValue;
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackAboveValue) 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.OnAttackAboveValue) continue;
int thr = def.attackTriggerValue;
if (thr < 0) thr = 0;
bool wasActive = _activeAttackAboveSkills.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)
{
_activeAttackAboveSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
}
else if (wasActive && !nowActive)
{
_activeAttackAboveSkills.Remove(def.skillId);
}
continue;
}
if (!wasActive && nowActive)
{
_activeAttackAboveSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
_appliedAttackAboveEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
}
else if (wasActive && !nowActive)
{
_activeAttackAboveSkills.Remove(def.skillId);
if (_appliedAttackAboveEffects.TryGetValue(def.skillId, out var applied))
{
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
_appliedAttackAboveEffects.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 = GameplayNow + 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);
}
// Frame guard so the global redirect list is swept once per frame, not once per ally
// (this is static/global state; running it 5× per frame was pure redundant work).
private static int s_lastRedirectCleanupFrame = -1;
private static void CleanupGlobalRedirects()
{
int frame = Time.frameCount;
if (frame == s_lastRedirectCleanupFrame) return;
s_lastRedirectCleanupFrame = frame;
// Nothing registered -> nothing to sweep.
if (s_nextDamageRedirectStates.Count == 0) return;
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 && GameplayNow > 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] = GameplayNow;
}
float t = 0f;
if (!_buffAppliedTimes.TryGetValue(buff.buffId, out t))
{
t = GameplayNow;
_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 = GameplayNow;
_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<AllyCombatant> 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) ?? SceneObjectLookupCache.Find($"ally_0{idx + 1}");
if (go == null) continue;
var ally = go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true);
eval(ally);
}
return best;
}
int left = slotIndex - 1;
int right = slotIndex + 1;
if (left >= 0)
{
var go = SceneObjectLookupCache.Find($"ally_0{left + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
eval(ally);
}
if (right <= 4)
{
var go = SceneObjectLookupCache.Find($"ally_0{right + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(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<AllyCombatant> 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) ?? SceneObjectLookupCache.Find($"ally_0{idx + 1}");
if (go == null) continue;
var ally = go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true);
eval(ally);
}
return best;
}
int left = slotIndex - 1;
int right = slotIndex + 1;
if (left >= 0)
{
var go = SceneObjectLookupCache.Find($"ally_0{left + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
eval(ally);
}
if (right <= 4)
{
var go = SceneObjectLookupCache.Find($"ally_0{right + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(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) - GameplayNow);
StartCoroutine(CompensateTransferredTimedBudeffAtExpiry(actualTarget, iconType, value, remaining));
}
return true;
}
private IEnumerator CompensateTransferredTimedBudeffAtExpiry(AllyCombatant target, PlayerBudeffIconType iconType, float value, float waitSeconds)
{
yield return GameplayClock.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 - GameplayNow);
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 = GameplayNow + 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<int> { 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 = GameplayNow + duration;
if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId);
_selfDamageRedirectToAdjacentIconId = null;
}
_selfDamageRedirectToAdjacentIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(this, PlayerBudeffIconType.dmg_redirect_toAdjacent, 0f, duration);
}
public void ActivateNonMissToPerfectRewrite(float duration)
{
if (!Application.isPlaying) return;
if (duration <= 0f) duration = 0.01f;
_rewriteNonMissToPerfectExpireTime = GameplayNow + 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 (GameplayNow > _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 && GameplayNow <= _rewriteNonMissToPerfectExpireTime;
}
private bool IsSelfDamageRedirectToAdjacentActive()
{
if (!Application.isPlaying) return false;
if (_selfDamageRedirectToAdjacentExpireTime <= 0f) return false;
if (IsDead)
{
ConsumeSelfDamageRedirectToAdjacent();
return false;
}
if (GameplayNow > _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;
}
}
}