Files
bansonic_beta_main/Assets/scripts/Combat/AllyCombatant.cs
T

3403 lines
128 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.
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
{
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;
// Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly.
private int _attackBaseUnbuffed = 0;
private bool _attackBaseInitialized = false;
// scoring fields
[Header("Scoring")]
[Tooltip("Base score value for a perfect hit on this track")]
public int baseTrackScore = 1000;
// Editable multipliers for each judge quality (exposed for designers)
[Tooltip("Score multiplier for Perfect (default 1.0)")]
public float perfectRatio = 1f;
[Tooltip("Score multiplier for Great (e.g. 0.75)")]
public float greatRatio = 0.75f;
[Tooltip("Score multiplier for Good (e.g. 0.5)")]
public float goodRatio = 0.5f;
[Tooltip("Score multiplier for Miss (e.g. 0)")]
public float missRatio = 0f;
[Tooltip("Documentation text normalized.")]
public int currentScore = 0;
[Tooltip("Documentation text normalized.")]
public int maxTrackScore = int.MaxValue - 1;
// When true, InitializeStatsFromData() will overwrite inspector values on Start.
// Set to false if you want to keep manual inspector values when entering Play mode.
public bool allowOverwriteFromSO = true;
// UI refs (resolved from teamUIController)
private Image characterImage;
private Image healthImage;
private Image fadeHealthImage;
private Image manaImage;
private Image fadeManaImage;
private TextMeshProUGUI nameText;
public string allyName => nameText != null ? nameText.text : name;
private TextMeshProUGUI healthRateText;
private TextMeshProUGUI currentScoreText;
private Image hurtRedImage;
// Gameplay HUD extras (legacy UI elements authored in the scene).
// Do not modify their transforms/style; only update runtime data.
private Text manaPercentTextLegacy; // child named "mana%"
private Image[] skillIconSlots; // children under "skill_img" (max 3)
private bool[] skillIconOccupied;
private Vector3[] skillIconBaseScales;
private bool[] skillIconBaseScaleCached;
[Tooltip("最多显示的已释放技能图标数量 (默认1)")]
public int maxSkillHistoryCount = 1;
[Tooltip("无新技能释放时,技能图标自动渐隐的时间 (秒)")]
public float skillIconAutoFadeTime = 2f;
private Coroutine fadeHealthCoroutine;
private Coroutine fadeManaCoroutine;
private List<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;
// 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;
// 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 && Time.time - lastSkillIconPushTime >= skillIconAutoFadeTime)
{
// Double check visibility
bool anyVisible = false;
if (skillIconOccupied != null)
{
for (int i = 0; i < skillIconOccupied.Length; i++)
{
if (skillIconOccupied[i]) { anyVisible = true; break; }
}
}
if (anyVisible)
{
skillIconAutoFadeCoroutine = StartCoroutine(SkillIconAutoFadeRoutine());
}
}
}
if (Application.isPlaying)
{
CleanupGlobalRedirects();
if (_pendingManaFullCastRetry && !_isCastingOnManaFull && currentMana >= maxMana && SkillBuilder.Instance != null)
{
_pendingManaFullCastRetry = false;
TryCastOnFullMana();
}
if (_selfDamageRedirectToAdjacentExpireTime > 0f && Time.time > _selfDamageRedirectToAdjacentExpireTime)
{
ConsumeSelfDamageRedirectToAdjacent();
}
if (_rewriteNonMissToPerfectExpireTime > 0f && Time.time > _rewriteNonMissToPerfectExpireTime)
{
ClearNonMissToPerfectRewrite();
}
else if (_rewriteNonMissToPerfectExpireTime > 0f &&
Time.time <= _rewriteNonMissToPerfectExpireTime &&
string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId) &&
iBudeffPrefabController.Instance != null)
{
// Fallback: if icon registration failed earlier (e.g. controller initialized later),
// retry during active window.
float remain = Mathf.Max(0.01f, _rewriteNonMissToPerfectExpireTime - Time.time);
_rewriteNonMissToPerfectIconId = iBudeffPrefabController.Instance.RegisterTimedEffect(this, PlayerBudeffIconType.ot_luckyChance, 1f, remain);
iBudeffPrefabController.Instance.RefreshAllyNow(this);
}
}
}
private void Start()
{
// Resolve UI and optionally pull data from SO
ResolveUIReferences();
ResolveHudExtras();
if (allowOverwriteFromSO)
InitializeStatsFromData();
// Initialize status
isDead = (maxHP <= 0);
currentHP = maxHP;
currentMana = 0;
UpdateUIImmediate();
// Ensure score UI shows initial value
UpdateScoreUI();
iBudeffPrefabController.Instance?.RegisterBaseline(this);
// Evaluate HP-percent triggers on start after a short delay to ensure singletons are ready
if (Application.isPlaying)
{
StartCoroutine(InitialTriggerCheckCoroutine());
}
}
private IEnumerator InitialTriggerCheckCoroutine()
{
// Wait for singletons to be ready
while (SkillBuilder.Instance == null || teamUIController.Instance == null)
{
yield return null;
}
// Wait until teamUIController has finished loading ally SOs if they are async
// or just wait a couple of frames to be sure
yield return null;
yield return null;
// Re-run initialization to ensure we have the correct MaxHP and skills from the loaded team
if (allowOverwriteFromSO)
{
InitializeStatsFromData();
currentHP = maxHP;
isDead = (maxHP <= 0);
UpdateUIImmediate();
}
iBudeffPrefabController.Instance?.RegisterBaseline(this);
// Final wait to ensure SkillBuilder's internal mapping is ready
while (SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex) == null)
{
yield return null;
}
// Perform the initial HP-percentage trigger check (oldHP = currentHP to trigger "ENTER" if condition met)
// Pass skipVFX=true to avoid firing trails/hit effects on scene start
EvaluateHPPercentageTriggers(currentHP, true);
EvaluateAttackZeroTriggers(true);
EvaluateAttackBelowTriggers(true);
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 : UnityEngine.Object.FindAnyObjectByType<teamUIController>();
if (ui == null) return;
switch (slotIndex)
{
case 0:
characterImage = ui.teammate01_characterImage;
healthImage = ui.teammate01_healthImage;
fadeHealthImage = ui.teammate01_fadehealthImage;
manaImage = ui.teammate01_manaImage;
fadeManaImage = ui.teammate01_fadeManaImage;
nameText = ui.teammate01_nameText;
healthRateText = ui.teammate01_healthRate;
currentScoreText = ui.teammate01_current_scoreText;
hurtRedImage = ui.teammate01_hurtRedImage;
break;
case 1:
characterImage = ui.teammate02_characterImage;
healthImage = ui.teammate02_healthImage;
fadeHealthImage = ui.teammate02_fadehealthImage;
manaImage = ui.teammate02_manaImage;
fadeManaImage = ui.teammate02_fadeManaImage;
nameText = ui.teammate02_nameText;
healthRateText = ui.teammate02_healthRate;
currentScoreText = ui.teammate02_current_scoreText;
hurtRedImage = ui.teammate02_hurtRedImage;
break;
case 2:
characterImage = ui.teammate03_characterImage;
healthImage = ui.teammate03_healthImage;
fadeHealthImage = ui.teammate03_fadehealthImage;
manaImage = ui.teammate03_manaImage;
fadeManaImage = ui.teammate03_fadeManaImage;
nameText = ui.teammate03_nameText;
healthRateText = ui.teammate03_healthRate;
currentScoreText = ui.teammate03_current_scoreText;
hurtRedImage = ui.teammate03_hurtRedImage;
break;
case 3:
characterImage = ui.teammate04_characterImage;
healthImage = ui.teammate04_healthImage;
fadeHealthImage = ui.teammate04_fadehealthImage;
manaImage = ui.teammate04_manaImage;
fadeManaImage = ui.teammate04_fadeManaImage;
nameText = ui.teammate04_nameText;
healthRateText = ui.teammate04_healthRate;
currentScoreText = ui.teammate04_current_scoreText;
hurtRedImage = ui.teammate04_hurtRedImage;
break;
case 4:
characterImage = ui.teammate05_characterImage;
healthImage = ui.teammate05_healthImage;
fadeHealthImage = ui.teammate05_fadehealthImage;
manaImage = ui.teammate05_manaImage;
fadeManaImage = ui.teammate05_fadeManaImage;
nameText = ui.teammate05_nameText;
healthRateText = ui.teammate05_healthRate;
currentScoreText = ui.teammate05_current_scoreText;
hurtRedImage = ui.teammate05_hurtRedImage;
break;
}
// If the assigned TMP field is null, attempt to resolve from the UI parent's object (teamUIController keeps objectFather references)
if (currentScoreText == null)
{
GameObject parent = null;
switch (slotIndex)
{
case 0: parent = ui.objectFather_ally01; break;
case 1: parent = ui.objectFather_ally02; break;
case 2: parent = ui.objectFather_ally03; break;
case 3: parent = ui.objectFather_ally04; break;
case 4: parent = ui.objectFather_ally05; break;
}
if (parent != null)
{
var tmp = parent.GetComponentInChildren<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 {parent.name} -> {tmp.gameObject.name}");
}
else
{
// legacy UnityEngine.UI.Text fallback
Text legacy = null;
try
{
// Avoid grabbing gameplay HUD extras like "mana%" which must stay legacy and is not score text.
var allLegacy = parent.GetComponentsInChildren<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 {parent.name}. Added/used TMP component on {go.name} and copied text.");
}
}
}
}
ResolveHudExtras();
}
private void ResolveHudExtras()
{
// These elements are part of the ally's UI hierarchy in the gameplay scene.
// Cache once to avoid per-frame GetComponent searches.
try
{
if (manaPercentTextLegacy == null)
{
var t = transform.Find("mana%") ?? FindChildRecursive(transform, "mana%");
if (t != null) manaPercentTextLegacy = t.GetComponent<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 = Time.time;
if (skillIconAutoFadeCoroutine != null)
{
StopCoroutine(skillIconAutoFadeCoroutine);
skillIconAutoFadeCoroutine = null;
}
int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length);
if (limit <= 0) return;
EnsureSkillIconSlotScaleCache();
NormalizeSkillIconSlotsVisualState();
// Capture the icon being pushed out (oldest active within limit) for exit animation.
int lastIndex = limit - 1;
Image lastSlot = skillIconSlots[lastIndex];
Sprite removedSprite = null;
Color removedColor = Color.white;
bool removedOccupied = skillIconOccupied != null &&
lastIndex < skillIconOccupied.Length &&
skillIconOccupied[lastIndex];
if (removedOccupied && lastSlot != null && lastSlot.sprite != null)
{
removedSprite = lastSlot.sprite;
removedColor = lastSlot.color;
}
// Shift down (oldest drops off the end).
for (int i = lastIndex; i > 0; i--)
{
var prev = skillIconSlots[i - 1];
var cur = skillIconSlots[i];
if (cur == null) continue;
cur.sprite = prev != null ? prev.sprite : null;
if (prev != null && skillIconOccupied != null && i - 1 < skillIconOccupied.Length)
{
cur.color = prev.color;
bool occupied = skillIconOccupied[i - 1];
cur.enabled = occupied || cur.sprite != null;
skillIconOccupied[i] = occupied;
}
else if (skillIconOccupied != null && i < skillIconOccupied.Length)
{
skillIconOccupied[i] = false;
}
}
// Insert at front.
var first = skillIconSlots[0];
if (first != null)
{
first.sprite = icon;
first.enabled = true;
first.color = Color.white;
if (skillIconOccupied != null && skillIconOccupied.Length > 0)
skillIconOccupied[0] = true;
PlaySkillIconEnterAnim(first);
}
// Clear slots beyond limit
for (int i = limit; i < skillIconSlots.Length; i++)
{
if (skillIconSlots[i] != null)
{
skillIconSlots[i].sprite = null;
skillIconSlots[i].enabled = false;
}
if (skillIconOccupied != null && i < skillIconOccupied.Length)
skillIconOccupied[i] = false;
}
ApplySkillIconSlotVisibility();
// Play exit animation for the removed icon (if any).
if (removedSprite != null && lastSlot != null)
{
PlaySkillIconExitAnim(lastSlot, removedSprite, removedColor);
}
}
private IEnumerator SkillIconAutoFadeRoutine()
{
if (skillIconSlots == null || skillIconOccupied == null)
{
skillIconAutoFadeCoroutine = null;
yield break;
}
// Fading from Left to Right (0 -> N)
// Assuming slot 0 is the newest and leftmost.
// If the user meant visual order, 0 is usually left.
int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length);
for (int i = 0; i < limit; i++)
{
if (i >= skillIconOccupied.Length) break;
// If new skill interrupts, this coroutine is stopped by PushSkillIcon
if (skillIconOccupied[i] && skillIconSlots[i] != null && skillIconSlots[i].sprite != null)
{
// Play exit animation for this slot
PlaySkillIconExitAnim(skillIconSlots[i], skillIconSlots[i].sprite, skillIconSlots[i].color);
// Clear the slot
skillIconSlots[i].sprite = null;
skillIconSlots[i].enabled = false;
skillIconOccupied[i] = false;
// Wait small interval between fades
yield return new WaitForSeconds(0.15f);
}
}
ApplySkillIconSlotVisibility();
skillIconAutoFadeCoroutine = null;
// Reset time so it doesn't loop immediately (though checks anyVisible)
lastSkillIconPushTime = -1f;
}
private void PlaySkillIconEnterAnim(Image img)
{
if (img == null) return;
if (!Application.isPlaying) return;
if (skillIconEnterCoroutine != null)
{
if (skillIconEnterAnimatingImage != null && skillIconEnterAnimatingImage.rectTransform != null)
{
int prevIndex = GetSkillIconSlotIndex(skillIconEnterAnimatingImage);
skillIconEnterAnimatingImage.rectTransform.localScale = GetSkillIconBaseScale(prevIndex, skillIconEnterAnimatingImage);
var prevColor = skillIconEnterAnimatingImage.color;
prevColor.a = 1f;
skillIconEnterAnimatingImage.color = prevColor;
}
StopCoroutine(skillIconEnterCoroutine);
}
skillIconEnterAnimatingImage = img;
if (gameObject.activeInHierarchy)
skillIconEnterCoroutine = StartCoroutine(SkillIconEnterCoroutine(img));
}
private IEnumerator SkillIconEnterCoroutine(Image img)
{
if (img == null) yield break;
var rt = img.rectTransform;
if (rt == null) yield break;
int slotIndex = GetSkillIconSlotIndex(img);
Vector3 baseScale = GetSkillIconBaseScale(slotIndex, img);
Color baseColor = img.color;
float duration = 0.24f;
float startScaleMult = 1.32f;
rt.localScale = baseScale * startScaleMult;
img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f);
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
float eased = 1f - Mathf.Pow(1f - u, 3f); // quick start, smooth finish
rt.localScale = Vector3.Lerp(baseScale * startScaleMult, baseScale, eased);
yield return null;
}
rt.localScale = baseScale;
img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f);
if (ReferenceEquals(skillIconEnterAnimatingImage, img))
{
skillIconEnterAnimatingImage = null;
skillIconEnterCoroutine = null;
}
}
private void PlaySkillIconExitAnim(Image hostSlot, Sprite removedSprite, Color removedColor)
{
if (hostSlot == null || removedSprite == null) return;
if (!Application.isPlaying) return;
// Render removed icon under the slot parent to avoid disturbing slot layout positions.
var go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
RectTransform hostRt = hostSlot.rectTransform;
RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null;
go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false);
var rt = go.GetComponent<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
Destroy(go);
}
private IEnumerator SkillIconExitCoroutine(GameObject go, RectTransform rt, Image img, Color baseColor, Image hostSlot)
{
if (go == null || rt == null || img == null) yield break;
float duration = 0.45f;
float t = 0f;
Vector2 startPos = rt.anchoredPosition;
// Move outward relative to the queue direction so removed icon does not overlap the new tail icon.
Vector2 exitDir = Vector2.right;
float slotGap = 0f;
int hostIndex = GetSkillIconSlotIndex(hostSlot);
if (hostIndex > 0 && skillIconSlots != null && hostIndex < skillIconSlots.Length)
{
var prev = skillIconSlots[hostIndex - 1];
if (prev != null && prev.rectTransform != null && hostSlot != null && hostSlot.rectTransform != null)
{
Vector2 prevPos = prev.rectTransform.anchoredPosition;
Vector2 hostPos = hostSlot.rectTransform.anchoredPosition;
Vector2 queueDir = hostPos - prevPos;
slotGap = queueDir.magnitude;
if (queueDir.sqrMagnitude > 0.0001f) exitDir = queueDir.normalized;
}
}
if (exitDir.sqrMagnitude < 0.0001f) exitDir = Vector2.right;
float iconWidth = Mathf.Max(1f, rt.rect.width * Mathf.Max(0.01f, Mathf.Abs(rt.localScale.x)));
float slideDistance = Mathf.Max(36f, iconWidth * 1.15f, slotGap * 1.35f);
Vector2 endPos = startPos + exitDir * slideDistance;
while (t < duration)
{
t += Time.deltaTime;
float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
float eased = 1f - Mathf.Pow(1f - u, 3f);
rt.anchoredPosition = Vector2.Lerp(startPos, endPos, eased);
var c = baseColor;
c.a = Mathf.Lerp(baseColor.a, 0f, u);
img.color = c;
yield return null;
}
if (go != null) Destroy(go);
}
private void InitializeStatsFromData()
{
bool dataFound = false;
// Try TeamCharacterDataInfo first
var arr = teamUIController.Instance?.GetCurrentAllySOs();
if (arr != null && slotIndex >= 0 && slotIndex < arr.Length)
{
var so = arr[slotIndex];
if (so != null)
{
dataFound = true;
// TeamCharacterDataInfo has limited fields; use CharacterMaxHealth if present
if (so.CharacterMaxHealth > 0)
{
maxHP = so.CharacterMaxHealth;
}
if (!string.IsNullOrEmpty(so.CharacterName) && nameText != null)
{
nameText.text = so.CharacterName;
}
if (characterImage != null && so.CharacterImage_gameplay != null)
{
characterImage.sprite = so.CharacterImage_gameplay.sprite;
}
}
}
// If more detailed info exists in AllyHero_SO, try to load by ally ID
int id = -1;
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null && slotIndex < teamUIController.Instance.allySlotIds.Count)
id = teamUIController.Instance.allySlotIds[slotIndex];
if (id > 0)
{
var all = Resources.LoadAll<AllyHero_SO>("");
foreach (var a in all)
{
if (a != null && a.ally_heroID == id)
{
dataFound = true;
// Documentation text normalized.
// Documentation text normalized.
// Documentation text normalized.
int expKeySlot = Mathf.Clamp(slotIndex + 1, 1, 5);
string expKey = $"selected_heroSlot0{expKeySlot}_exp";
int slotExp = a.ally_currentEXP;
if (PlayerPrefs.HasKey(expKey))
slotExp = PlayerPrefs.GetInt(expKey, a.ally_currentEXP);
// pick effective level based on per-slot EXP
if (a.levelStats != null && a.levelStats.Count > 0)
{
var eff = GetEffectiveLevelForExp(a, slotExp);
if (eff != null)
{
maxHP = eff.maxHP;
maxMana = eff.maxMana;
damageResistance = eff.damageResistance;
scoreEfficiency = eff.scoreEfficiency;
SetAttack(eff.attack);
}
else
{
var lvl = a.levelStats[0];
maxHP = lvl.maxHP;
maxMana = lvl.maxMana;
damageResistance = lvl.damageResistance;
scoreEfficiency = lvl.scoreEfficiency;
SetAttack(lvl.attack);
}
}
if (nameText != null) nameText.text = a.ally_heroName;
if (characterImage != null && a.ally_heroProfile != null) characterImage.sprite = a.ally_heroProfile;
break;
}
}
}
// If no data found, set default "empty" values
if (!dataFound)
{
maxHP = 0;
currentHP = 0;
maxMana = 0;
currentMana = 0;
if (nameText != null) nameText.text = "——";
// Ensure UI shows -/- immediately
UpdateUIImmediate();
}
}
// Documentation text normalized.
private static AllyHero_SO.AllyLevelInfo GetEffectiveLevelForExp(AllyHero_SO so, int exp)
{
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
AllyHero_SO.AllyLevelInfo best = null;
foreach (var lvl in so.levelStats)
{
if (lvl == null) continue;
if (exp >= lvl.requiredEXP)
{
if (best == null || lvl.requiredEXP >= best.requiredEXP)
best = lvl;
}
}
return best ?? so.levelStats[0];
}
// Scoring API
// Modified to return the amount added so callers can track per-track contributions
public int AddScoreForJudge(string judge)
{
if (IsDead) return 0; // dead characters cannot gain score
int add = 0;
float multiplier = 0f;
switch (judge)
{
case "Perfect":
multiplier = perfectRatio;
break;
case "Great":
multiplier = greatRatio;
break;
case "Good":
multiplier = goodRatio;
break;
case "Miss":
multiplier = missRatio;
break;
default:
multiplier = 0f;
break;
}
// Use perNoteScore from BeatmapManager as the base score for each note hit
int baseScore = bmm != null ? bmm.perNoteScore : baseTrackScore;
add = Mathf.FloorToInt(baseScore * multiplier);
int before = currentScore;
currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore);
int actuallyAdded = currentScore - before;
UpdateScoreUI();
// update global total
ScoreManager.Instance?.RecalculateTotal();
EvaluateAttackZeroTriggers();
return actuallyAdded;
}
// Add an arbitrary score delta directly (used by skills to modify single judge or grant/penalize score)
public void AddScoreDirect(int delta)
{
if (IsDead) return; // dead characters cannot gain/lose score
currentScore = Mathf.Clamp(currentScore + delta, 0, maxTrackScore);
UpdateScoreUI();
// Skill-driven score changes are idol-score changes and must be reflected in ScoreManager aggregates.
// This keeps right-top scoreboard/allSum_idolScore in sync with floating score popups.
if (slotIndex >= 0 && slotIndex < 5 && delta != 0)
{
ScoreManager.Instance?.AddIdolScoreForTrack(slotIndex, delta);
}
ScoreManager.Instance?.RecalculateTotal();
}
private void UpdateScoreUI()
{
string value = $"{currentScore}/{maxTrackScore}";
if (currentScoreText != null)
{
currentScoreText.text = value;
LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})");
return;
}
// As a fallback, try to write directly to teamUIController.Instance fields if available
var ui = teamUIController.Instance;
if (ui != null)
{
switch (slotIndex)
{
case 0:
if (ui.teammate01_current_scoreText != null) { ui.teammate01_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate01_current_scoreText: {value}"); }
break;
case 1:
if (ui.teammate02_current_scoreText != null) { ui.teammate02_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate02_current_scoreText: {value}"); }
break;
case 2:
if (ui.teammate03_current_scoreText != null) { ui.teammate03_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate03_current_scoreText: {value}"); }
break;
case 3:
if (ui.teammate04_current_scoreText != null) { ui.teammate04_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate04_current_scoreText: {value}"); }
break;
case 4:
if (ui.teammate05_current_scoreText != null) { ui.teammate05_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate05_current_scoreText: {value}"); }
break;
}
}
}
// Set max score cap for this track and refresh UI
public void SetMaxTrackScore(int newMax, bool clampCurrent = true)
{
maxTrackScore = Mathf.Max(0, newMax);
if (clampCurrent)
currentScore = Mathf.Clamp(currentScore, 0, maxTrackScore);
UpdateScoreUI();
}
// Public setters that update UI and optionally animate fade bars
public void SetCurrentHP(int hp, bool animateFade = true)
{
SetCurrentHP(hp, animateFade, false);
}
public void SetCurrentHP(int hp, bool animateFade, bool skipPopup)
{
if (isDead) return; // dead characters cannot change HP
int old = currentHP;
currentHP = Mathf.Clamp(hp, 0, maxHP);
if (currentHP <= 0) isDead = true;
UpdateHealthVisuals(old, animateFade);
// Trigger HP changed events
int delta = currentHP - old;
if (delta != 0 && !skipPopup)
{
if (iNumberPrefabController.Instance != null)
{
var type = delta > 0 ? iNumberPrefabController.InstantNumberType.Heal : iNumberPrefabController.InstantNumberType.Damage;
iNumberPrefabController.SpawnForAllyStatic(slotIndex, type, delta);
}
}
if (delta > 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPHealed);
}
else if (delta < 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPLost);
}
// Defeated event (fire once on transition >0 -> 0)
if (old > 0 && currentHP <= 0)
{
ClearNonMissToPerfectRewrite();
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnSelfDefeated);
string dName = "Unknown";
if (nameText != null) dName = nameText.text;
else if (SkillBuilder.Instance != null)
{
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so != null) dName = so.ally_heroName;
}
SkillTriggerFeedUI.PushDeath(dName);
}
// Evaluate HP-percentage based trigger transitions (entry/exit)
EvaluateHPPercentageTriggers(old);
}
// Internal HP modifier that does NOT apply heal multipliers (used by ReceiveHeal to avoid double rounding).
private void ModifyHPRaw(int delta, bool animateFade = true)
{
ModifyHPRaw(delta, animateFade, false);
}
private void ModifyHPRaw(int delta, bool animateFade, bool skipPopup)
{
if (isDead && delta > 0)
{
// cannot heal a dead unit
return;
}
SetCurrentHP(currentHP + delta, animateFade, skipPopup);
}
public void ModifyHP(int delta, bool animateFade = true)
{
ModifyHP(delta, animateFade, false);
}
public void ModifyHP(int delta, bool animateFade, bool skipPopup)
{
if (isDead && delta > 0)
{
// cannot heal a dead unit
return;
}
// Apply heal-received multipliers to all positive HP deltas so both ReceiveHeal() and direct ModifyHP(+)
// paths respect heal reduction / amplification buffs.
if (delta > 0)
{
float mult = GetTotalHealReceivedMultiplier();
if (!Mathf.Approximately(mult, 1f))
{
delta = Mathf.CeilToInt(delta * mult);
}
}
ModifyHPRaw(delta, animateFade, skipPopup);
}
public void SetMaxHP(int newMax, bool keepCurrentRatio = true)
{
if (newMax < 1) newMax = 1;
int oldMax = maxHP;
int oldHP = currentHP;
float ratio = maxHP > 0 ? (float)currentHP / maxHP : 1f;
maxHP = newMax;
if (keepCurrentRatio)
currentHP = Mathf.Clamp(Mathf.RoundToInt(ratio * maxHP), 0, maxHP);
else
currentHP = Mathf.Clamp(currentHP, 0, maxHP);
UpdateUIImmediate();
// Max HP change affects percentage triggers
EvaluateHPPercentageTriggers(oldHP);
}
public void SetCurrentMana(int mana, bool animateFade = true, bool flashVisuals = true)
{
if (isDead) return; // dead characters cannot change mana
int old = currentMana;
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)
{
if (iNumberPrefabController.Instance != null)
{
var type = delta > 0 ? iNumberPrefabController.InstantNumberType.ManaPlus : iNumberPrefabController.InstantNumberType.ManaMinus;
iNumberPrefabController.SpawnForAllyStatic(slotIndex, type, delta);
}
}
if (delta > 0)
{
if (_isTriggeringManaGained) return;
_isTriggeringManaGained = true;
try
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaGained);
}
finally
{
_isTriggeringManaGained = false;
}
}
else if (delta < 0)
{
if (_isTriggeringManaLost) return;
_isTriggeringManaLost = true;
try
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaLost);
}
finally
{
_isTriggeringManaLost = false;
}
}
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 old = attack;
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult));
if (old != attack)
{
EvaluateAttackZeroTriggers();
EvaluateAttackBelowTriggers();
EvaluateAttackAboveTriggers();
}
}
public void ModifyAttack(int delta)
{
EnsureAttackBaseInitialized();
_attackBaseUnbuffed = Mathf.Max(0, _attackBaseUnbuffed + delta);
RecalculateAttackFromBuffs();
}
public void SetAttack(int value)
{
EnsureAttackBaseInitialized();
_attackBaseUnbuffed = Mathf.Max(0, value);
RecalculateAttackFromBuffs();
}
private void UpdateHealthVisuals(int oldHP, bool animateFade)
{
// main bar should snap quickly to new value, while fade bar lerps towards it
if (healthImage != null)
{
float newFill = maxHP > 0 ? (float)currentHP / maxHP : 0f;
// snap main bar quickly for visual pop
healthImage.fillAmount = Mathf.MoveTowards(healthImage.fillAmount, newFill, 1f);
}
if (healthRateText != null)
healthRateText.text = maxHP > 0 ? $"{currentHP}/{maxHP}" : "-/-";
if (fadeHealthCoroutine != null) StopCoroutine(fadeHealthCoroutine);
if (fadeHealthImage != null)
{
float target = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
if (animateFade && Application.isPlaying && gameObject.activeInHierarchy)
fadeHealthCoroutine = StartCoroutine(FadeHealthCoroutine(target));
else
fadeHealthImage.fillAmount = target;
}
// HP change flash effects
if (hurtRedImage != null && Application.isPlaying && gameObject.activeInHierarchy)
{
var ui = teamUIController.Instance;
if (ui != null)
{
if (currentHP < oldHP)
{
// Damage flash (Red)
if (!isDead)
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.hurtColor));
}
else if (currentHP > oldHP)
{
// Heal flash (Green)
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.healColor));
}
}
}
// if dead, trigger grayscale effect on character image
if (isDead && characterImage != null && Application.isPlaying && gameObject.activeInHierarchy)
{
var ui = teamUIController.Instance;
if (ui != null)
{
// Documentation text normalized.
StartCoroutine(ui.AllyDeathGrayscale(characterImage));
}
}
}
private void UpdateManaVisuals(int oldMana, bool animateFade, bool flashVisuals = true)
{
// main bar snaps, fade bar lerps to new value for visual effect
if (manaImage != null)
{
float newFill = maxMana > 0 ? (float)currentMana / maxMana : 0f;
manaImage.fillAmount = Mathf.MoveTowards(manaImage.fillAmount, newFill, 1f);
}
if (fadeManaCoroutine != null) StopCoroutine(fadeManaCoroutine);
if (fadeManaImage != null)
{
float target = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
if (animateFade && Application.isPlaying && gameObject.activeInHierarchy)
fadeManaCoroutine = StartCoroutine(FadeManaCoroutine(target));
else
fadeManaImage.fillAmount = target;
}
// Mana recovery flash effect
if (flashVisuals && currentMana > oldMana && hurtRedImage != null && Application.isPlaying && gameObject.activeInHierarchy)
{
var ui = teamUIController.Instance;
if (ui != null)
{
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.manaColor));
}
}
UpdateManaPercentLegacyText();
}
private void UpdateUIImmediate()
{
if (healthImage != null)
{
healthImage.fillAmount = maxHP > 0 ? (float)currentHP / maxHP : 0f;
}
if (fadeHealthImage != null)
{
// keep fade bar equal to main bar immediately
fadeHealthImage.fillAmount = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
}
if (manaImage != null)
{
manaImage.fillAmount = maxMana > 0 ? (float)currentMana / maxMana : 0f;
}
if (fadeManaImage != null)
{
fadeManaImage.fillAmount = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
}
if (healthRateText != null)
{
healthRateText.text = maxHP > 0 ? $"{currentHP}/{maxHP}" : "-/-";
}
UpdateManaPercentLegacyText();
}
private IEnumerator FadeHealthCoroutine(float targetFill)
{
if (fadeHealthImage == null) yield break;
float start = fadeHealthImage.fillAmount;
float duration = 0.6f;
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
fadeHealthImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
yield return null;
}
fadeHealthImage.fillAmount = targetFill;
}
private IEnumerator FadeManaCoroutine(float targetFill)
{
if (fadeManaImage == null) yield break;
float start = fadeManaImage.fillAmount;
float duration = 0.4f;
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
fadeManaImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
yield return null;
}
fadeManaImage.fillAmount = targetFill;
}
public void ReceiveDamage(float amount, GameObject source, bool deferPopup = false)
{
ReceiveDamageInternal(amount, source, true, deferPopup);
}
private void ReceiveDamageInternal(float amount, GameObject source, bool allowRedirect, bool deferPopup)
{
if (IsDead) return; // dead characters cannot be hit
if (allowRedirect && TryGetGlobalRedirectTargetForReceiverDamage(this, out var redirector))
{
ConsumeGlobalRedirect(redirector);
if (redirector != null && redirector != this)
{
redirector.ReceiveDamageInternal(amount, source, false, deferPopup);
return;
}
// If redirector is self or invalid, consume redirect and continue to take damage normally.
}
// Self damage redirection: while active, redirect damage that would hit THIS ally to an adjacent ally.
// Do not redirect self-inflicted costs (source == this.gameObject) to avoid breaking "pay HP" skills.
if (allowRedirect && IsSelfDamageRedirectToAdjacentActive() && source != this.gameObject)
{
var adj = FindAdjacentRedirectTargetForDamage();
if (adj != null && adj != this)
{
ConsumeSelfDamageRedirectToAdjacent();
adj.ReceiveDamageInternal(amount, source, false, deferPopup);
return;
}
}
int oldHP = currentHP;
float effective = amount * (1f - damageResistance);
int delta = Mathf.CeilToInt(effective);
ModifyHP(-delta, true, true);
int actual = oldHP - currentHP;
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);
}
}
}
public void ReceiveHeal(float amount, GameObject source, bool deferPopup = false)
{
if (IsDead) return; // dead characters cannot be healed
int oldHP = currentHP;
float mult = GetTotalHealReceivedMultiplier();
int delta = Mathf.CeilToInt(amount * mult);
ModifyHP(delta, true, true);
int actual = currentHP - oldHP;
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);
}
}
}
public void ApplyBuff(Buff buff, GameObject source)
{
ApplyBuffInternal(buff, source, true);
}
private void ApplyBuffInternal(Buff buff, GameObject source, bool allowRedirect)
{
if (buff == null) return;
if (string.IsNullOrWhiteSpace(buff.buffId))
{
buff.buffId = Guid.NewGuid().ToString();
}
if (allowRedirect && TryRedirectIncomingGenericBuff(out var redirectedTarget) && redirectedTarget != null && redirectedTarget != this)
{
RegisterForwardedBuffTarget(buff.buffId, redirectedTarget);
redirectedTarget.ApplyBuffInternal(buff, source, false);
return;
}
activeBuffs.Add(buff);
_buffAppliedTimes[buff.buffId] = Application.isPlaying ? Time.time : Time.realtimeSinceStartup;
// Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted.
if (buff.scoreMultiplier != 1f) scoreEfficiency *= buff.scoreMultiplier;
if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs();
iBudeffPrefabController.Instance?.NotifyBuffApplied(this, buff);
}
public void RemoveBuff(string buffId)
{
var b = activeBuffs.Find(x => x.buffId == buffId);
if (b != null)
{
activeBuffs.Remove(b);
if (b.scoreMultiplier != 1f) scoreEfficiency /= b.scoreMultiplier;
if (b.attackMultiplier != 1f) RecalculateAttackFromBuffs();
iBudeffPrefabController.Instance?.NotifyBuffRemoved(this, b);
_forwardedBuffRemoveTargets.Remove(buffId);
_buffAppliedTimes.Remove(buffId);
return;
}
if (_forwardedBuffRemoveTargets.TryGetValue(buffId, out var forwarded) && forwarded != null)
{
_forwardedBuffRemoveTargets.Remove(buffId);
forwarded.RemoveBuff(buffId);
}
}
private void TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger when)
{
if (!Application.isPlaying) return;
if (SkillBuilder.Instance == null) return;
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null) return;
// If equipped groups exist, iterate them first
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null) continue;
foreach (var def in group.skills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
}
return;
}
// Fallback: check primary group
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null)
{
foreach (var def in fallbackGroup.skills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
return;
}
// Final fallback: iterate availableSkills
if (so.availableSkills != null)
{
foreach (var def in so.availableSkills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
}
}
private void TryCastOnFullMana()
{
if (!Application.isPlaying) return;
if (_isCastingOnManaFull) return;
if (currentMana < maxMana) return;
if (SkillBuilder.Instance == null)
{
_pendingManaFullCastRetry = true;
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will retry when ready.");
return;
}
// Safety: avoid infinite loops if a cast immediately refills mana to full in the same frame.
if (_lastManaFullCastFrame == Time.frameCount) return;
_lastManaFullCastFrame = Time.frameCount;
_isCastingOnManaFull = true;
_pendingManaGainAfterManaFullCast = 0;
try
{
// Attempt to cast primary skill configured in SO via SkillBuilder
bool anyTriggered = CastSkill();
if (anyTriggered)
{
SetCurrentMana(0, true, false);
}
}
finally
{
_isCastingOnManaFull = false;
}
// Apply any buffered mana gains now that mana has been spent.
if (_pendingManaGainAfterManaFullCast != 0)
{
int pending = _pendingManaGainAfterManaFullCast;
_pendingManaGainAfterManaFullCast = 0;
ModifyMana(pending, true, true);
}
}
private bool CastSkill()
{
// Use SkillBuilder to invoke the primary skill defined in the AllyHero_SO for this slot
if (SkillBuilder.Instance == null)
{
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: SkillBuilder.Instance is null.");
return false;
}
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null)
{
// Can't locate SO for this slot; attempt best-effort: call UsePrimarySkillForSlot which will log details
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1}: AllyHero_SO not found for slot. Falling back to UsePrimarySkillForSlot.");
SkillBuilder.Instance.UsePrimarySkillForSlot(slotIndex, -1f, null);
return true;
}
bool anyTriggered = false;
// First: equipped groups
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (int gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } }
if (group == null) continue;
foreach (var skill in group.skills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
// Second: primary group
if (!anyTriggered)
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null)
{
foreach (var skill in fallbackGroup.skills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from primary group: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
// Third: availableSkills / primary skill fallback
if (!anyTriggered)
{
// try primary skill specifically
var def = so.GetPrimarySkill();
if (def != null && def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull)
{
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull.");
anyTriggered = true;
}
else if (so.availableSkills != null)
{
foreach (var skill in so.availableSkills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast available skill: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
if (!anyTriggered)
{
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1}: no skills configured for OnManaFull.");
}
else
{
SkillBuilder.Instance?.TriggerOnAdjacentAllySkillCast(slotIndex);
}
return anyTriggered;
}
private void CastSkillGroup(SkillGroup group, int slotIndex)
{
if (group == null) return;
// For auto-casting triggered by ManaFull, only cast skills that are configured to trigger on ManaFull.
for (int i = 0; i < group.skills.Length; i++)
{
var skill = group.skills[i];
if (skill == null) continue;
// Only cast skills whose triggerCondition matches OnManaFull when this method is invoked from auto-cast path
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull)
{
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} skill '{skill.skillId}' skipped: triggerCondition={skill.triggerCondition}.");
continue;
}
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId}");
}
}
public void TriggerOnManaFull()
{
if (currentMana >= maxMana)
{
TryCastOnFullMana();
}
}
[ContextMenu("Test Fill Mana")]
private void TestFillMana()
{
SetCurrentMana(maxMana, true);
}
private Dictionary<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();
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null || group.skills == null) continue;
for (int i = 0; i < group.skills.Length; i++)
{
var def = group.skills[i];
if (def != null) defs.Add(def);
}
}
}
else
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null && fallbackGroup.skills != null)
{
for (int i = 0; i < fallbackGroup.skills.Length; i++)
{
var def = fallbackGroup.skills[i];
if (def != null) defs.Add(def);
}
}
else if (so.availableSkills != null)
{
for (int i = 0; i < so.availableSkills.Length; i++)
{
var def = so.availableSkills[i];
if (def != null) defs.Add(def);
}
}
}
var vars = BuildFormulaVars(so);
// Select a single "best" Above/Below skill to avoid stacking when multiple thresholds are satisfied.
string selectedAbove = null;
float selectedAboveThreshold = float.MinValue;
string selectedBelow = null;
float selectedBelowThreshold = float.MaxValue;
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent)
{
float threshold = def.hpTriggerPercent;
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
{
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
}
threshold = Mathf.Clamp01(threshold);
if (currPct >= threshold && threshold >= selectedAboveThreshold)
{
selectedAboveThreshold = threshold;
selectedAbove = def.skillId;
}
}
else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent)
{
float threshold = def.hpTriggerPercent;
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
{
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
}
threshold = Mathf.Clamp01(threshold);
if (currPct <= threshold && threshold <= selectedBelowThreshold)
{
selectedBelowThreshold = threshold;
selectedBelow = def.skillId;
}
}
}
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnHPAbovePercent && def.triggerCondition != SkillDefinition.SkillTrigger.OnHPBelowPercent)
continue;
float threshold = def.hpTriggerPercent;
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
{
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
}
threshold = Mathf.Clamp01(threshold);
bool wasActive = _activeHpPercentSkills.Contains(def.skillId);
bool nowActive = false;
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent)
nowActive = (selectedAbove != null && def.skillId == selectedAbove) && currPct >= threshold;
else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent)
nowActive = (selectedBelow != null && def.skillId == selectedBelow) && currPct <= threshold;
// Evaluate formula into amount using similar variables as SkillBuilder
float amount = 0f;
if (!string.IsNullOrWhiteSpace(def.formula))
{
if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
amount = fresult;
}
// Decide if this effect type is reversible (state-based)
bool isReversible = IsEffectReversible(def.effectType);
if (!isReversible)
{
// For non-reversible effects (like Damage or instant Heal), we don't track state
// Just trigger it once when the condition is first met
if (!wasActive && nowActive)
{
_activeHpPercentSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
}
else if (wasActive && !nowActive)
{
_activeHpPercentSkills.Remove(def.skillId);
}
continue;
}
if (!wasActive && nowActive)
{
// ENTER condition: Apply effect and record amount
_activeHpPercentSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
_appliedHpPercentEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
}
else if (wasActive && !nowActive)
{
// LEAVE condition: Revert effect using recorded amount
_activeHpPercentSkills.Remove(def.skillId);
if (_appliedHpPercentEffects.TryGetValue(def.skillId, out var applied))
{
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
_appliedHpPercentEffects.Remove(def.skillId);
}
}
}
}
private void EvaluateAttackZeroTriggers(bool skipVFX = false)
{
if (!Application.isPlaying) return;
if (SkillBuilder.Instance == null) return;
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null) return;
bool nowActive = attack <= 0;
var defs = _tmpSkillDefs;
defs.Clear();
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null || group.skills == null) continue;
for (int i = 0; i < group.skills.Length; i++)
{
var def = group.skills[i];
if (def != null) defs.Add(def);
}
}
}
else
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null && fallbackGroup.skills != null)
{
for (int i = 0; i < fallbackGroup.skills.Length; i++)
{
var def = fallbackGroup.skills[i];
if (def != null) defs.Add(def);
}
}
else if (so.availableSkills != null)
{
for (int i = 0; i < so.availableSkills.Length; i++)
{
var def = so.availableSkills[i];
if (def != null) defs.Add(def);
}
}
}
var vars = BuildFormulaVars(so);
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackZero) continue;
bool wasActive = _activeAttackZeroSkills.Contains(def.skillId);
// Evaluate formula into amount using similar variables as SkillBuilder
float amount = 0f;
if (!string.IsNullOrWhiteSpace(def.formula))
{
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
amount = 0f;
else
amount = fresult;
}
bool isReversible = IsEffectReversible(def.effectType);
if (!isReversible)
{
if (!wasActive && nowActive)
{
_activeAttackZeroSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
}
else if (wasActive && !nowActive)
{
_activeAttackZeroSkills.Remove(def.skillId);
}
continue;
}
if (!wasActive && nowActive)
{
_activeAttackZeroSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
_appliedAttackZeroEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
}
else if (wasActive && !nowActive)
{
_activeAttackZeroSkills.Remove(def.skillId);
if (_appliedAttackZeroEffects.TryGetValue(def.skillId, out var applied))
{
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
_appliedAttackZeroEffects.Remove(def.skillId);
}
}
}
}
private void EvaluateAttackBelowTriggers(bool skipVFX = false)
{
if (!Application.isPlaying) return;
if (SkillBuilder.Instance == null) return;
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null) return;
int currAttack = attack;
var defs = _tmpSkillDefs;
defs.Clear();
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null || group.skills == null) continue;
for (int i = 0; i < group.skills.Length; i++)
{
var def = group.skills[i];
if (def != null) defs.Add(def);
}
}
}
else
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null && fallbackGroup.skills != null)
{
for (int i = 0; i < fallbackGroup.skills.Length; i++)
{
var def = fallbackGroup.skills[i];
if (def != null) defs.Add(def);
}
}
else if (so.availableSkills != null)
{
for (int i = 0; i < so.availableSkills.Length; i++)
{
var def = so.availableSkills[i];
if (def != null) defs.Add(def);
}
}
}
// Pick the most restrictive threshold that is still satisfied (smallest attackTriggerValue such that attack < value).
string selected = null;
int selectedThreshold = int.MaxValue;
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue;
int thr = def.attackTriggerValue;
if (thr < 0) thr = 0;
if (currAttack < thr && thr <= selectedThreshold)
{
selectedThreshold = thr;
selected = def.skillId;
}
}
var vars = BuildFormulaVars(so);
for (int i = 0; i < defs.Count; i++)
{
var def = defs[i];
if (def == null) continue;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue;
int thr = def.attackTriggerValue;
if (thr < 0) thr = 0;
bool wasActive = _activeAttackBelowSkills.Contains(def.skillId);
bool nowActive = (selected != null && def.skillId == selected) && (currAttack < thr);
float amount = 0f;
if (!string.IsNullOrWhiteSpace(def.formula))
{
if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
amount = fresult;
}
bool isReversible = IsEffectReversible(def.effectType);
if (!isReversible)
{
if (!wasActive && nowActive)
{
_activeAttackBelowSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
}
else if (wasActive && !nowActive)
{
_activeAttackBelowSkills.Remove(def.skillId);
}
continue;
}
if (!wasActive && nowActive)
{
_activeAttackBelowSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null, 1f, skipVFX);
_appliedAttackBelowEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
}
else if (wasActive && !nowActive)
{
_activeAttackBelowSkills.Remove(def.skillId);
if (_appliedAttackBelowEffects.TryGetValue(def.skillId, out var applied))
{
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
_appliedAttackBelowEffects.Remove(def.skillId);
}
}
}
}
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();
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null || group.skills == null) continue;
for (int i = 0; i < group.skills.Length; i++)
{
var def = group.skills[i];
if (def != null) defs.Add(def);
}
}
}
else
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null && fallbackGroup.skills != null)
{
for (int i = 0; i < fallbackGroup.skills.Length; i++)
{
var def = fallbackGroup.skills[i];
if (def != null) defs.Add(def);
}
}
else if (so.availableSkills != null)
{
for (int i = 0; i < so.availableSkills.Length; i++)
{
var def = so.availableSkills[i];
if (def != null) defs.Add(def);
}
}
}
// Pick the 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 = Time.time + duration;
}
NextDamageRedirectState state = null;
for (int i = 0; i < s_nextDamageRedirectStates.Count; i++)
{
var s = s_nextDamageRedirectStates[i];
if (s == null || s.redirector != redirector) continue;
state = s;
break;
}
if (state == null)
{
state = new NextDamageRedirectState { redirector = redirector };
s_nextDamageRedirectStates.Add(state);
}
else if (!string.IsNullOrEmpty(state.iconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId);
state.iconId = null;
}
state.expireTime = expireTime;
state.activationOrder = ++s_nextDamageRedirectOrderSeed;
state.iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(redirector, PlayerBudeffIconType.dmg_redirect_toSelf, 0f, duration);
}
private static void CleanupGlobalRedirects()
{
for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--)
{
var state = s_nextDamageRedirectStates[i];
var redirector = state != null ? state.redirector : null;
bool remove = redirector == null;
if (!remove && (redirector.IsDead || !redirector.gameObject.activeInHierarchy))
remove = true;
if (!remove && Application.isPlaying && state.expireTime > 0f && Time.time > state.expireTime)
remove = true;
if (!remove) continue;
if (state != null && redirector != null && !string.IsNullOrEmpty(state.iconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId);
}
s_nextDamageRedirectStates.RemoveAt(i);
}
}
private static void ConsumeGlobalRedirect(AllyCombatant redirector)
{
if (redirector == null) return;
for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--)
{
var state = s_nextDamageRedirectStates[i];
if (state == null) continue;
if (state.redirector != redirector) continue;
if (!string.IsNullOrEmpty(state.iconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(redirector.slotIndex, state.iconId);
}
s_nextDamageRedirectStates.RemoveAt(i);
}
}
private static bool AreAdjacentAllies(AllyCombatant a, AllyCombatant b)
{
if (a == null || b == null) return false;
return Mathf.Abs(a.slotIndex - b.slotIndex) == 1;
}
private static bool TryGetGlobalRedirectTargetForReceiver(AllyCombatant receiver, out AllyCombatant redirector)
{
redirector = null;
if (receiver == null) return false;
CleanupGlobalRedirects();
if (s_nextDamageRedirectStates.Count == 0) return false;
NextDamageRedirectState best = null;
for (int i = 0; i < s_nextDamageRedirectStates.Count; i++)
{
var state = s_nextDamageRedirectStates[i];
if (state == null || state.redirector == null) continue;
if (state.redirector == receiver) continue;
if (!AreAdjacentAllies(receiver, state.redirector)) continue;
if (best == null || state.activationOrder > best.activationOrder)
{
best = state;
}
}
if (best == null) return false;
redirector = best.redirector;
return redirector != null;
}
private static float GetHpRatioForRedirect(AllyCombatant ally)
{
if (ally == null) return 0f;
if (ally.maxHP <= 0) return 0f;
return Mathf.Clamp01((float)ally.currentHP / ally.maxHP);
}
// For "交给我!" damage redirect: among eligible adjacent redirectors,
// pick the one with LOWER HP ratio first. Tie-breaker: latest activation.
private static bool TryGetGlobalRedirectTargetForReceiverDamage(AllyCombatant receiver, out AllyCombatant redirector)
{
redirector = null;
if (receiver == null) return false;
CleanupGlobalRedirects();
if (s_nextDamageRedirectStates.Count == 0) return false;
NextDamageRedirectState best = null;
float bestHpRatio = float.PositiveInfinity;
const float eps = 0.0001f;
for (int i = 0; i < s_nextDamageRedirectStates.Count; i++)
{
var state = s_nextDamageRedirectStates[i];
if (state == null || state.redirector == null) continue;
if (state.redirector == receiver) continue;
if (!AreAdjacentAllies(receiver, state.redirector)) continue;
float hpRatio = GetHpRatioForRedirect(state.redirector);
if (best == null || hpRatio < bestHpRatio - eps)
{
best = state;
bestHpRatio = hpRatio;
continue;
}
if (Mathf.Abs(hpRatio - bestHpRatio) <= eps && state.activationOrder > best.activationOrder)
{
best = state;
bestHpRatio = hpRatio;
}
}
if (best == null) return false;
redirector = best.redirector;
return redirector != null;
}
private void RegisterForwardedBuffTarget(string buffId, AllyCombatant target)
{
if (string.IsNullOrWhiteSpace(buffId) || target == null) return;
_forwardedBuffRemoveTargets[buffId] = target;
}
private Buff GetTransferableExistingBuff()
{
Buff latest = null;
float latestTime = float.NegativeInfinity;
for (int i = activeBuffs.Count - 1; i >= 0; i--)
{
var buff = activeBuffs[i];
if (buff == null) continue;
if (string.IsNullOrWhiteSpace(buff.buffId))
{
buff.buffId = Guid.NewGuid().ToString();
_buffAppliedTimes[buff.buffId] = Application.isPlaying ? Time.time : Time.realtimeSinceStartup;
}
float t = 0f;
if (!_buffAppliedTimes.TryGetValue(buff.buffId, out t))
{
t = Application.isPlaying ? Time.time : Time.realtimeSinceStartup;
_buffAppliedTimes[buff.buffId] = t;
}
if (latest == null || t > latestTime)
{
latest = buff;
latestTime = t;
}
}
return latest;
}
private void RemoveLocalBuffInstance(Buff buff)
{
if (buff == null) return;
if (!activeBuffs.Remove(buff)) return;
if (buff.scoreMultiplier != 1f) scoreEfficiency /= buff.scoreMultiplier;
if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs();
if (!string.IsNullOrWhiteSpace(buff.buffId))
{
_forwardedBuffRemoveTargets.Remove(buff.buffId);
_buffAppliedTimes.Remove(buff.buffId);
}
iBudeffPrefabController.Instance?.NotifyBuffRemoved(this, buff);
}
private bool TransferOneExistingBuffTo(AllyCombatant target)
{
if (target == null || target == this) return false;
if (IsDead || target.IsDead) return false;
if (!target.gameObject.activeInHierarchy) return false;
var objectBuff = GetTransferableExistingBuff();
bool hasObjectBuff = objectBuff != null;
float objectBuffTime = float.NegativeInfinity;
if (hasObjectBuff && !string.IsNullOrWhiteSpace(objectBuff.buffId))
{
if (!_buffAppliedTimes.TryGetValue(objectBuff.buffId, out objectBuffTime))
{
objectBuffTime = Application.isPlaying ? Time.time : Time.realtimeSinceStartup;
_buffAppliedTimes[objectBuff.buffId] = objectBuffTime;
}
}
float budeffTime = float.NegativeInfinity;
bool hasBudeff = iBudeffPrefabController.Instance != null &&
iBudeffPrefabController.Instance.TryGetLatestAllyBuffTimestamp(this, out budeffTime);
if (!hasObjectBuff && !hasBudeff) return false;
if (hasObjectBuff && (!hasBudeff || objectBuffTime >= budeffTime))
{
return TryTransferExistingBuffObjectTo(target, objectBuff);
}
return TryTransferOneExistingBudeffTo(target);
}
private int GetBuffCountForRedirectSelection()
{
var ctrl = iBudeffPrefabController.Instance;
if (ctrl != null && ctrl.TryGetTransferableAllyBuffGroupCount(this, out int groupCount))
{
// If there are visible transferable budeff groups, use that as the primary count.
if (groupCount > 0) return groupCount;
}
// Fallback for non-budeff object buffs.
return activeBuffs != null ? activeBuffs.Count : 0;
}
private AllyCombatant FindAdjacentAllyByBuffCount(bool pickMost, bool requireAtLeastOneBuff)
{
AllyCombatant best = null;
int bestCount = pickMost ? int.MinValue : int.MaxValue;
System.Action<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) ?? GameObject.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 = GameObject.Find($"ally_0{left + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
eval(ally);
}
if (right <= 4)
{
var go = GameObject.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) ?? GameObject.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 = GameObject.Find($"ally_0{left + 1}");
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
eval(ally);
}
if (right <= 4)
{
var go = GameObject.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) - Time.time);
StartCoroutine(CompensateTransferredTimedBudeffAtExpiry(actualTarget, iconType, value, remaining));
}
return true;
}
private IEnumerator CompensateTransferredTimedBudeffAtExpiry(AllyCombatant target, PlayerBudeffIconType iconType, float value, float waitSeconds)
{
yield return new WaitForSeconds(Mathf.Max(0.01f, waitSeconds));
if (target == null) yield break;
if (this == null) yield break;
// Inverse move to neutralize original source-side timed revert and end target-side buff.
ApplyTransferredBudeffEffect(target, iconType, -value);
var ctrl = iBudeffPrefabController.Instance;
if (ctrl != null)
{
ctrl.RefreshAllyNow(this);
ctrl.RefreshAllyNow(target);
}
}
private void ApplyTransferredBudeffEffect(AllyCombatant target, PlayerBudeffIconType iconType, float value)
{
if (target == null) return;
switch (iconType)
{
case PlayerBudeffIconType.ot_maxHP_up:
case PlayerBudeffIconType.ot_maxHP_down:
{
int delta = Mathf.RoundToInt(value);
if (delta != 0)
{
ApplyMaxHPDelta(this, -delta);
ApplyMaxHPDelta(target, delta);
}
}
break;
case PlayerBudeffIconType.ot_maxMana_up:
case PlayerBudeffIconType.ot_maxMana_down:
{
int delta = Mathf.RoundToInt(value);
if (delta != 0)
{
ApplyMaxManaDelta(this, -delta);
ApplyMaxManaDelta(target, delta);
}
}
break;
case PlayerBudeffIconType.ot_scoreEfficiency_up:
case PlayerBudeffIconType.ot_scoreEfficiency_down:
if (!Mathf.Approximately(value, 0f))
{
scoreEfficiency = Mathf.Max(0f, scoreEfficiency - value);
target.scoreEfficiency = Mathf.Max(0f, target.scoreEfficiency + value);
}
break;
case PlayerBudeffIconType.ot_atk_up:
case PlayerBudeffIconType.ot_atk_down:
{
int delta = Mathf.RoundToInt(value);
if (delta != 0)
{
ModifyAttack(-delta);
target.ModifyAttack(delta);
}
}
break;
case PlayerBudeffIconType.ot_defend_up:
case PlayerBudeffIconType.ot_defend_down:
case PlayerBudeffIconType.ot_vulnerability:
if (!Mathf.Approximately(value, 0f))
{
damageResistance = Mathf.Min(damageResistance - value, 1f);
target.damageResistance = Mathf.Min(target.damageResistance + value, 1f);
}
break;
case PlayerBudeffIconType.dmg_redirect_toSelf:
TransferRedirectToSelfStateTo(target);
break;
case PlayerBudeffIconType.dmg_redirect_toAdjacent:
TransferRedirectToAdjacentStateTo(target);
break;
}
}
private AllyCombatant ResolveRedirectTargetForIncomingBuff(AllyCombatant intendedTarget)
{
if (intendedTarget == null) return null;
if (intendedTarget.IsDead || !intendedTarget.gameObject.activeInHierarchy) return intendedTarget;
if (intendedTarget.TryRedirectIncomingGenericBuff(out var redirected) && redirected != null)
{
return redirected;
}
return intendedTarget;
}
private void TransferRedirectToSelfStateTo(AllyCombatant target)
{
if (target == null || target == this) return;
if (target.IsDead || !target.gameObject.activeInHierarchy) return;
CleanupGlobalRedirects();
// Keep single state per redirector target to avoid duplicate one-shot slots.
ConsumeGlobalRedirect(target);
NextDamageRedirectState state = null;
for (int i = 0; i < s_nextDamageRedirectStates.Count; i++)
{
var s = s_nextDamageRedirectStates[i];
if (s == null || s.redirector != this) continue;
if (state == null || s.activationOrder > state.activationOrder) state = s;
}
if (state == null) return;
state.redirector = target;
state.activationOrder = ++s_nextDamageRedirectOrderSeed;
}
private void TransferRedirectToAdjacentStateTo(AllyCombatant target)
{
if (target == null || target == this) return;
if (target.IsDead || !target.gameObject.activeInHierarchy) return;
if (!IsSelfDamageRedirectToAdjacentActive()) return;
float remain = Mathf.Max(0.01f, _selfDamageRedirectToAdjacentExpireTime - Time.time);
string iconId = _selfDamageRedirectToAdjacentIconId;
// Clear source state without touching icon (icon already moved by budeff controller transfer).
_selfDamageRedirectToAdjacentExpireTime = -1f;
_selfDamageRedirectToAdjacentIconId = null;
// Keep only one self-redirect state on target.
target.ConsumeSelfDamageRedirectToAdjacent();
target._selfDamageRedirectToAdjacentExpireTime = Time.time + remain;
target._selfDamageRedirectToAdjacentIconId = iconId;
}
private static void ApplyMaxHPDelta(AllyCombatant ally, int delta)
{
if (ally == null || delta == 0) return;
if (delta > 0)
{
ally.SetMaxHP(Mathf.Max(1, ally.maxHP + delta), false);
ally.SetCurrentHP(ally.currentHP + delta, false);
}
else
{
ally.SetMaxHP(Mathf.Max(1, ally.maxHP + delta), false);
}
}
private static void ApplyMaxManaDelta(AllyCombatant ally, int delta)
{
if (ally == null || delta == 0) return;
ally.SetMaxMana(Mathf.Max(1, ally.maxMana + delta), false);
}
private bool TryPeekLatestTransferableBuffTime(out float latestTime)
{
latestTime = float.NegativeInfinity;
bool found = false;
for (int i = 0; i < activeBuffs.Count; i++)
{
var buff = activeBuffs[i];
if (buff == null || string.IsNullOrWhiteSpace(buff.buffId)) continue;
if (!_buffAppliedTimes.TryGetValue(buff.buffId, out float t)) continue;
if (!found || t > latestTime)
{
latestTime = t;
found = true;
}
}
var ctrl = iBudeffPrefabController.Instance;
if (ctrl != null && ctrl.TryGetLatestAllyBuffTimestamp(this, out float buffTime))
{
if (!found || buffTime > latestTime)
{
latestTime = buffTime;
found = true;
}
}
return found;
}
private bool TryTransferExistingBuffFromAdjacentToSelf()
{
// "交给我!" rule: pick adjacent ally with the FEWEST buffs, then take one buff.
var donor = FindAdjacentAllyByBuffCount(pickMost: false, requireAtLeastOneBuff: true);
return donor != null && donor.TransferOneExistingBuffTo(this);
}
// For direct buff-like effects that do not go through ApplyBuff(Buff,...):
// resolve redirect chain and consume one-shot redirect states along the path.
public bool TryRedirectIncomingGenericBuff(out AllyCombatant redirectedTarget)
{
redirectedTarget = this;
if (IsDead) return false;
AllyCombatant current = this;
bool moved = false;
const int maxHop = 6;
int hop = 0;
var visited = new HashSet<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 = Time.time + duration;
if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId);
_selfDamageRedirectToAdjacentIconId = null;
}
_selfDamageRedirectToAdjacentIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(this, PlayerBudeffIconType.dmg_redirect_toAdjacent, 0f, duration);
}
public void ActivateNonMissToPerfectRewrite(float duration)
{
if (!Application.isPlaying) return;
if (duration <= 0f) duration = 0.01f;
_rewriteNonMissToPerfectExpireTime = Time.time + duration;
if (!string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _rewriteNonMissToPerfectIconId);
_rewriteNonMissToPerfectIconId = null;
}
_rewriteNonMissToPerfectIconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(this, PlayerBudeffIconType.ot_luckyChance, 1f, duration);
iBudeffPrefabController.Instance?.RefreshAllyNow(this);
}
public bool IsNonMissToPerfectRewriteActive()
{
if (!Application.isPlaying) return false;
if (_rewriteNonMissToPerfectExpireTime <= 0f) return false;
if (Time.time > _rewriteNonMissToPerfectExpireTime)
{
ClearNonMissToPerfectRewrite();
return false;
}
if (IsDead)
{
ClearNonMissToPerfectRewrite();
return false;
}
return true;
}
private void ClearNonMissToPerfectRewrite()
{
_rewriteNonMissToPerfectExpireTime = -1f;
if (!string.IsNullOrEmpty(_rewriteNonMissToPerfectIconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _rewriteNonMissToPerfectIconId);
_rewriteNonMissToPerfectIconId = null;
}
iBudeffPrefabController.Instance?.RefreshAllyNow(this);
}
public bool HasNonMissToPerfectRewriteForUI()
{
if (!Application.isPlaying) return false;
if (IsDead) return false;
return _rewriteNonMissToPerfectExpireTime > 0f && Time.time <= _rewriteNonMissToPerfectExpireTime;
}
private bool IsSelfDamageRedirectToAdjacentActive()
{
if (!Application.isPlaying) return false;
if (_selfDamageRedirectToAdjacentExpireTime <= 0f) return false;
if (IsDead)
{
ConsumeSelfDamageRedirectToAdjacent();
return false;
}
if (Time.time > _selfDamageRedirectToAdjacentExpireTime)
{
ConsumeSelfDamageRedirectToAdjacent();
return false;
}
return true;
}
private void ConsumeSelfDamageRedirectToAdjacent()
{
_selfDamageRedirectToAdjacentExpireTime = -1f;
if (!string.IsNullOrEmpty(_selfDamageRedirectToAdjacentIconId))
{
iBudeffPrefabController.Instance?.UnregisterTimedEffect(slotIndex, _selfDamageRedirectToAdjacentIconId);
_selfDamageRedirectToAdjacentIconId = null;
}
}
private AllyCombatant FindAdjacentRedirectTarget()
{
// For "都给你!", route to adjacent ally with the MOST buffs.
return FindAdjacentAllyByBuffCount(pickMost: true, requireAtLeastOneBuff: false);
}
private AllyCombatant FindAdjacentRedirectTargetForDamage()
{
// For "都给你!" damage redirect: higher HP adjacent ally is preferred.
return FindAdjacentAllyByHealth(pickHigherHp: true);
}
private bool IsEffectReversible(EffectType type)
{
switch (type)
{
case EffectType.IncreaseAttack:
case EffectType.DecreaseAttack:
case EffectType.IncreaseMaxHP:
case EffectType.DecreaseMaxHP:
case EffectType.IncreaseMaxMana:
case EffectType.DecreaseMaxMana:
case EffectType.IncreaseScoreEfficiency:
case EffectType.DecreaseScoreEfficiency:
case EffectType.IncreaseDamageResistance:
case EffectType.DecreaseDamageResistance:
case EffectType.ScoreMultiplier:
case EffectType.AddScore:
return true;
default:
return false;
}
}
private void ApplyInverseEffect(Selector selector, EffectType type, float amount)
{
if (EffectSystem.Instance == null) return;
switch (type)
{
case EffectType.IncreaseAttack:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseAttack, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseAttack:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseAttack, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseMaxHP:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxHP, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseMaxHP:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxHP, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseMaxMana:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxMana, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseMaxMana:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxMana, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseScoreEfficiency:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseScoreEfficiency, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseScoreEfficiency:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseScoreEfficiency, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseDamageResistance:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseDamageResistance, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseDamageResistance:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseDamageResistance, amount, 0f, this.gameObject, null);
break;
case EffectType.ScoreMultiplier:
if (Mathf.Abs(amount) > 0.0001f)
EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, 1f / amount, 0f, this.gameObject, null);
break;
case EffectType.AddScore:
EffectSystem.Instance.ApplyEffect(selector, EffectType.AddScore, -amount, 0f, this.gameObject, null);
break;
default:
// Fallback: apply same type with negative amount if it's likely a numeric stat
EffectSystem.Instance.ApplyEffect(selector, type, -amount, 0f, this.gameObject, null);
break;
}
}
}