3403 lines
128 KiB
C#
3403 lines
128 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|