2197 lines
83 KiB
C#
2197 lines
83 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 redirect (next hit) ---
|
||
private static AllyCombatant s_nextDamageRedirector;
|
||
private static float s_redirectExpireTime = -1f;
|
||
|
||
// --- Self damage redirect to adjacent (duration) ---
|
||
private float _selfDamageRedirectToAdjacentExpireTime = -1f;
|
||
|
||
// 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;
|
||
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;
|
||
|
||
private Coroutine fadeHealthCoroutine;
|
||
private Coroutine fadeManaCoroutine;
|
||
private List<Buff> activeBuffs = new List<Buff>();
|
||
|
||
private Coroutine skillIconEnterCoroutine;
|
||
private Image skillIconEnterAnimatingImage;
|
||
|
||
// 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>();
|
||
|
||
// Prevent re-entrant mana-trigger loops, e.g. OnManaGained -> (skill adds mana) -> OnManaGained -> ...
|
||
private bool _isTriggeringManaGained = false;
|
||
private bool _isTriggeringManaLost = false;
|
||
|
||
// Prevent OnManaFull auto-cast recursion and allow "restore mana on spend" skills to work correctly.
|
||
private bool _isCastingOnManaFull = false;
|
||
private int _pendingManaGainAfterManaFullCast = 0;
|
||
private int _lastManaFullCastFrame = -1;
|
||
|
||
// Reusable buffers to reduce allocations during skill evaluation
|
||
private readonly List<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 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>();
|
||
}
|
||
|
||
private void LogVerbose(string message)
|
||
{
|
||
if (GameConfig.verboseLogs) Debug.Log(message);
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
// Resolve UI and optionally pull data from SO
|
||
ResolveUIReferences();
|
||
ResolveHudExtras();
|
||
if (allowOverwriteFromSO)
|
||
InitializeStatsFromData();
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
isDead = false;
|
||
currentHP = Mathf.Max(1, maxHP);
|
||
currentMana = 0;
|
||
|
||
UpdateUIImmediate();
|
||
// Ensure score UI shows initial value
|
||
UpdateScoreUI();
|
||
|
||
// Evaluate HP-percent triggers on start after a short delay to ensure singletons are ready
|
||
if (Application.isPlaying)
|
||
{
|
||
StartCoroutine(InitialTriggerCheckCoroutine());
|
||
}
|
||
}
|
||
|
||
private IEnumerator InitialTriggerCheckCoroutine()
|
||
{
|
||
// Wait for singletons to be ready
|
||
while (SkillBuilder.Instance == null || teamUIController.Instance == null)
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
// Wait until teamUIController has finished loading ally SOs if they are async
|
||
// or just wait a couple of frames to be sure
|
||
yield return null;
|
||
yield return null;
|
||
|
||
// Re-run initialization to ensure we have the correct MaxHP and skills from the loaded team
|
||
if (allowOverwriteFromSO)
|
||
{
|
||
InitializeStatsFromData();
|
||
currentHP = Mathf.Max(1, maxHP);
|
||
UpdateUIImmediate();
|
||
}
|
||
|
||
// Final wait to ensure SkillBuilder's internal mapping is ready
|
||
while (SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex) == null)
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
// Perform the initial HP-percentage trigger check (oldHP = currentHP to trigger "ENTER" if condition met)
|
||
EvaluateHPPercentageTriggers(currentHP);
|
||
EvaluateAttackZeroTriggers();
|
||
EvaluateAttackBelowTriggers();
|
||
}
|
||
|
||
// Allow editing in inspector to immediately reflect on UI (editor only)
|
||
private void OnValidate()
|
||
{
|
||
// Avoid heavy editor-only operations, but resolve UI references so inspector changes show
|
||
ResolveUIReferences();
|
||
// Clamp values to valid ranges
|
||
if (maxHP < 1) maxHP = 1;
|
||
if (maxMana < 1) maxMana = 1;
|
||
currentHP = Mathf.Clamp(currentHP, 0, maxHP);
|
||
currentMana = Mathf.Clamp(currentMana, 0, maxMana);
|
||
|
||
// Do not start coroutines from OnValidate - update immediately
|
||
UpdateUIImmediate();
|
||
|
||
// If user adjusts currentHP or currentMana in inspector while in Play mode, attempt to trigger behaviors
|
||
if (Application.isPlaying && SkillBuilder.Instance != null)
|
||
{
|
||
EvaluateHPPercentageTriggers(currentHP);
|
||
EvaluateAttackZeroTriggers();
|
||
EvaluateAttackBelowTriggers();
|
||
TryCastOnFullMana();
|
||
}
|
||
}
|
||
|
||
private void ResolveUIReferences()
|
||
{
|
||
// Try singleton first, fallback to scene search (works in editor)
|
||
var ui = teamUIController.Instance != null ? teamUIController.Instance : UnityEngine.Object.FindAnyObjectByType<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;
|
||
|
||
bool occupied = i < skillIconOccupied.Length && skillIconOccupied[i];
|
||
slot.enabled = occupied || slot.sprite != null;
|
||
|
||
Color c = slot.color;
|
||
c.a = occupied ? 1f : 0f;
|
||
slot.color = c;
|
||
}
|
||
|
||
ForceRebuildSkillIconLayout();
|
||
}
|
||
|
||
private static int CompareSkillIconSlotsByScreenOrder(Image a, Image b)
|
||
{
|
||
if (a == null && b == null) return 0;
|
||
if (a == null) return 1;
|
||
if (b == null) return -1;
|
||
|
||
Vector3 pa = a.rectTransform != null ? a.rectTransform.position : a.transform.position;
|
||
Vector3 pb = b.rectTransform != null ? b.rectTransform.position : b.transform.position;
|
||
|
||
int xOrder = pa.x.CompareTo(pb.x); // left -> right on screen
|
||
if (xOrder != 0) return xOrder;
|
||
return -pa.y.CompareTo(pb.y);
|
||
}
|
||
|
||
private void ForceRebuildSkillIconLayout()
|
||
{
|
||
if (skillIconSlots == null || skillIconSlots.Length == 0) return;
|
||
|
||
RectTransform parent = skillIconSlots[0] != null && skillIconSlots[0].rectTransform != null
|
||
? skillIconSlots[0].rectTransform.parent as RectTransform
|
||
: null;
|
||
if (parent == null) return;
|
||
|
||
try
|
||
{
|
||
LayoutRebuilder.ForceRebuildLayoutImmediate(parent);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
private static Transform FindChildRecursive(Transform root, string name)
|
||
{
|
||
if (root == null || string.IsNullOrEmpty(name)) return null;
|
||
for (int i = 0; i < root.childCount; i++)
|
||
{
|
||
var c = root.GetChild(i);
|
||
if (c == null) continue;
|
||
if (c.name == name) return c;
|
||
var found = FindChildRecursive(c, name);
|
||
if (found != null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private void UpdateManaPercentLegacyText()
|
||
{
|
||
if (manaPercentTextLegacy == null) return;
|
||
int max = Mathf.Max(1, maxMana);
|
||
int cur = Mathf.Clamp(currentMana, 0, max);
|
||
int percent = cur <= 0 ? 0 : (cur * 100 + max - 1) / max; // ceil without floats
|
||
manaPercentTextLegacy.text = percent.ToString() + "%";
|
||
}
|
||
|
||
/// <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;
|
||
EnsureSkillIconSlotScaleCache();
|
||
NormalizeSkillIconSlotsVisualState();
|
||
|
||
// Capture the icon being pushed out (oldest) for exit animation.
|
||
Image lastSlot = skillIconSlots[skillIconSlots.Length - 1];
|
||
Sprite removedSprite = null;
|
||
Color removedColor = Color.white;
|
||
bool removedOccupied = skillIconOccupied != null &&
|
||
skillIconSlots.Length - 1 < skillIconOccupied.Length &&
|
||
skillIconOccupied[skillIconSlots.Length - 1];
|
||
if (removedOccupied && lastSlot != null && lastSlot.sprite != null)
|
||
{
|
||
removedSprite = lastSlot.sprite;
|
||
removedColor = lastSlot.color;
|
||
}
|
||
|
||
// Shift down (oldest drops off the end).
|
||
for (int i = skillIconSlots.Length - 1; 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);
|
||
}
|
||
ApplySkillIconSlotVisibility();
|
||
|
||
// Play exit animation for the removed icon (if any).
|
||
if (removedSprite != null && lastSlot != null)
|
||
{
|
||
PlaySkillIconExitAnim(lastSlot, removedSprite, removedColor);
|
||
}
|
||
}
|
||
|
||
private void PlaySkillIconEnterAnim(Image img)
|
||
{
|
||
if (img == null) return;
|
||
if (!Application.isPlaying) return;
|
||
|
||
if (skillIconEnterCoroutine != null)
|
||
{
|
||
if (skillIconEnterAnimatingImage != null && skillIconEnterAnimatingImage.rectTransform != null)
|
||
{
|
||
int prevIndex = GetSkillIconSlotIndex(skillIconEnterAnimatingImage);
|
||
skillIconEnterAnimatingImage.rectTransform.localScale = GetSkillIconBaseScale(prevIndex, skillIconEnterAnimatingImage);
|
||
var prevColor = skillIconEnterAnimatingImage.color;
|
||
prevColor.a = 1f;
|
||
skillIconEnterAnimatingImage.color = prevColor;
|
||
}
|
||
StopCoroutine(skillIconEnterCoroutine);
|
||
}
|
||
|
||
skillIconEnterAnimatingImage = img;
|
||
skillIconEnterCoroutine = StartCoroutine(SkillIconEnterCoroutine(img));
|
||
}
|
||
|
||
private IEnumerator SkillIconEnterCoroutine(Image img)
|
||
{
|
||
if (img == null) yield break;
|
||
var rt = img.rectTransform;
|
||
if (rt == null) yield break;
|
||
|
||
int slotIndex = GetSkillIconSlotIndex(img);
|
||
Vector3 baseScale = GetSkillIconBaseScale(slotIndex, img);
|
||
Color baseColor = img.color;
|
||
|
||
float duration = 0.24f;
|
||
float startScaleMult = 1.32f;
|
||
|
||
rt.localScale = baseScale * startScaleMult;
|
||
img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f);
|
||
|
||
float t = 0f;
|
||
while (t < duration)
|
||
{
|
||
t += Time.deltaTime;
|
||
float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
|
||
float eased = 1f - Mathf.Pow(1f - u, 3f); // quick start, smooth finish
|
||
rt.localScale = Vector3.Lerp(baseScale * startScaleMult, baseScale, eased);
|
||
yield return null;
|
||
}
|
||
|
||
rt.localScale = baseScale;
|
||
img.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1f);
|
||
|
||
if (ReferenceEquals(skillIconEnterAnimatingImage, img))
|
||
{
|
||
skillIconEnterAnimatingImage = null;
|
||
skillIconEnterCoroutine = null;
|
||
}
|
||
}
|
||
|
||
private void PlaySkillIconExitAnim(Image hostSlot, Sprite removedSprite, Color removedColor)
|
||
{
|
||
if (hostSlot == null || removedSprite == null) return;
|
||
if (!Application.isPlaying) return;
|
||
|
||
// Render removed icon under the slot parent to avoid disturbing slot layout positions.
|
||
var go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
|
||
RectTransform hostRt = hostSlot.rectTransform;
|
||
RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null;
|
||
go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false);
|
||
|
||
var rt = go.GetComponent<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;
|
||
|
||
StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot));
|
||
}
|
||
|
||
private IEnumerator SkillIconExitCoroutine(GameObject go, RectTransform rt, Image img, Color baseColor, Image hostSlot)
|
||
{
|
||
if (go == null || rt == null || img == null) yield break;
|
||
|
||
float duration = 0.45f;
|
||
float t = 0f;
|
||
Vector2 startPos = rt.anchoredPosition;
|
||
|
||
// Move outward relative to the queue direction so removed icon does not overlap the new tail icon.
|
||
Vector2 exitDir = Vector2.right;
|
||
float slotGap = 0f;
|
||
int hostIndex = GetSkillIconSlotIndex(hostSlot);
|
||
if (hostIndex > 0 && skillIconSlots != null && hostIndex < skillIconSlots.Length)
|
||
{
|
||
var prev = skillIconSlots[hostIndex - 1];
|
||
if (prev != null && prev.rectTransform != null && hostSlot != null && hostSlot.rectTransform != null)
|
||
{
|
||
Vector2 prevPos = prev.rectTransform.anchoredPosition;
|
||
Vector2 hostPos = hostSlot.rectTransform.anchoredPosition;
|
||
Vector2 queueDir = hostPos - prevPos;
|
||
slotGap = queueDir.magnitude;
|
||
if (queueDir.sqrMagnitude > 0.0001f) exitDir = queueDir.normalized;
|
||
}
|
||
}
|
||
|
||
if (exitDir.sqrMagnitude < 0.0001f) exitDir = Vector2.right;
|
||
|
||
float iconWidth = Mathf.Max(1f, rt.rect.width * Mathf.Max(0.01f, Mathf.Abs(rt.localScale.x)));
|
||
float slideDistance = Mathf.Max(36f, iconWidth * 1.15f, slotGap * 1.35f);
|
||
Vector2 endPos = startPos + exitDir * slideDistance;
|
||
|
||
while (t < duration)
|
||
{
|
||
t += Time.deltaTime;
|
||
float u = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
|
||
float eased = 1f - Mathf.Pow(1f - u, 3f);
|
||
rt.anchoredPosition = Vector2.Lerp(startPos, endPos, eased);
|
||
|
||
var c = baseColor;
|
||
c.a = Mathf.Lerp(baseColor.a, 0f, u);
|
||
img.color = c;
|
||
|
||
yield return null;
|
||
}
|
||
|
||
if (go != null) Destroy(go);
|
||
}
|
||
|
||
private void InitializeStatsFromData()
|
||
{
|
||
// Try TeamCharacterDataInfo first
|
||
var arr = teamUIController.Instance?.GetCurrentAllySOs();
|
||
if (arr != null && slotIndex >= 0 && slotIndex < arr.Length)
|
||
{
|
||
var so = arr[slotIndex];
|
||
if (so != null)
|
||
{
|
||
// TeamCharacterDataInfo has limited fields; use CharacterMaxHealth if present
|
||
if (so.CharacterMaxHealth > 0)
|
||
{
|
||
maxHP = so.CharacterMaxHealth;
|
||
}
|
||
if (!string.IsNullOrEmpty(so.CharacterName) && nameText != null)
|
||
{
|
||
nameText.text = so.CharacterName;
|
||
}
|
||
if (characterImage != null && so.CharacterImage_gameplay != null)
|
||
{
|
||
characterImage.sprite = so.CharacterImage_gameplay.sprite;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If more detailed info exists in AllyHero_SO, try to load by ally ID
|
||
int id = -1;
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null && slotIndex < teamUIController.Instance.allySlotIds.Count)
|
||
id = teamUIController.Instance.allySlotIds[slotIndex];
|
||
|
||
if (id > 0)
|
||
{
|
||
var all = Resources.LoadAll<AllyHero_SO>("");
|
||
foreach (var a in all)
|
||
{
|
||
if (a != null && a.ally_heroID == id)
|
||
{
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
int expKeySlot = Mathf.Clamp(slotIndex + 1, 1, 5);
|
||
string expKey = $"selected_heroSlot0{expKeySlot}_exp";
|
||
int slotExp = a.ally_currentEXP;
|
||
if (PlayerPrefs.HasKey(expKey))
|
||
slotExp = PlayerPrefs.GetInt(expKey, a.ally_currentEXP);
|
||
|
||
// pick effective level based on per-slot EXP
|
||
if (a.levelStats != null && a.levelStats.Count > 0)
|
||
{
|
||
var eff = GetEffectiveLevelForExp(a, slotExp);
|
||
if (eff != null)
|
||
{
|
||
maxHP = eff.maxHP;
|
||
maxMana = eff.maxMana;
|
||
damageResistance = eff.damageResistance;
|
||
scoreEfficiency = eff.scoreEfficiency;
|
||
SetAttack(eff.attack);
|
||
}
|
||
else
|
||
{
|
||
var lvl = a.levelStats[0];
|
||
maxHP = lvl.maxHP;
|
||
maxMana = lvl.maxMana;
|
||
damageResistance = lvl.damageResistance;
|
||
scoreEfficiency = lvl.scoreEfficiency;
|
||
SetAttack(lvl.attack);
|
||
}
|
||
}
|
||
|
||
if (nameText != null) nameText.text = a.ally_heroName;
|
||
if (characterImage != null && a.ally_heroProfile != null) characterImage.sprite = a.ally_heroProfile;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
private static AllyHero_SO.AllyLevelInfo GetEffectiveLevelForExp(AllyHero_SO so, int exp)
|
||
{
|
||
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
|
||
AllyHero_SO.AllyLevelInfo best = null;
|
||
foreach (var lvl in so.levelStats)
|
||
{
|
||
if (lvl == null) continue;
|
||
if (exp >= lvl.requiredEXP)
|
||
{
|
||
if (best == null || lvl.requiredEXP >= best.requiredEXP)
|
||
best = lvl;
|
||
}
|
||
}
|
||
return best ?? so.levelStats[0];
|
||
}
|
||
|
||
// Scoring API
|
||
// Modified to return the amount added so callers can track per-track contributions
|
||
public int AddScoreForJudge(string judge)
|
||
{
|
||
if (IsDead) return 0; // dead characters cannot gain score
|
||
int add = 0;
|
||
float multiplier = 0f;
|
||
switch (judge)
|
||
{
|
||
case "Perfect":
|
||
multiplier = perfectRatio;
|
||
break;
|
||
case "Great":
|
||
multiplier = greatRatio;
|
||
break;
|
||
case "Good":
|
||
multiplier = goodRatio;
|
||
break;
|
||
case "Miss":
|
||
multiplier = missRatio;
|
||
break;
|
||
default:
|
||
multiplier = 0f;
|
||
break;
|
||
}
|
||
|
||
// Use perNoteScore from BeatmapManager as the base score for each note hit
|
||
int baseScore = bmm != null ? bmm.perNoteScore : baseTrackScore;
|
||
add = Mathf.FloorToInt(baseScore * multiplier);
|
||
|
||
int before = currentScore;
|
||
currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore);
|
||
int actuallyAdded = currentScore - before;
|
||
UpdateScoreUI();
|
||
// update global total
|
||
ScoreManager.Instance?.RecalculateTotal();
|
||
EvaluateAttackZeroTriggers();
|
||
return actuallyAdded;
|
||
}
|
||
|
||
// Add an arbitrary score delta directly (used by skills to modify single judge or grant/penalize score)
|
||
public void AddScoreDirect(int delta)
|
||
{
|
||
if (IsDead) return; // dead characters cannot gain/lose score
|
||
currentScore = Mathf.Clamp(currentScore + delta, 0, maxTrackScore);
|
||
UpdateScoreUI();
|
||
ScoreManager.Instance?.RecalculateTotal();
|
||
}
|
||
|
||
private void UpdateScoreUI()
|
||
{
|
||
string value = $"{currentScore}/{maxTrackScore}";
|
||
if (currentScoreText != null)
|
||
{
|
||
currentScoreText.text = value;
|
||
LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})");
|
||
return;
|
||
}
|
||
|
||
// As a fallback, try to write directly to teamUIController.Instance fields if available
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
switch (slotIndex)
|
||
{
|
||
case 0:
|
||
if (ui.teammate01_current_scoreText != null) { ui.teammate01_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate01_current_scoreText: {value}"); }
|
||
break;
|
||
case 1:
|
||
if (ui.teammate02_current_scoreText != null) { ui.teammate02_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate02_current_scoreText: {value}"); }
|
||
break;
|
||
case 2:
|
||
if (ui.teammate03_current_scoreText != null) { ui.teammate03_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate03_current_scoreText: {value}"); }
|
||
break;
|
||
case 3:
|
||
if (ui.teammate04_current_scoreText != null) { ui.teammate04_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate04_current_scoreText: {value}"); }
|
||
break;
|
||
case 4:
|
||
if (ui.teammate05_current_scoreText != null) { ui.teammate05_current_scoreText.text = value; LogVerbose($"[AllyCombatant] Fallback wrote to ui.teammate05_current_scoreText: {value}"); }
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set max score cap for this track and refresh UI
|
||
public void SetMaxTrackScore(int newMax, bool clampCurrent = true)
|
||
{
|
||
maxTrackScore = Mathf.Max(0, newMax);
|
||
if (clampCurrent)
|
||
currentScore = Mathf.Clamp(currentScore, 0, maxTrackScore);
|
||
UpdateScoreUI();
|
||
}
|
||
|
||
// Public setters that update UI and optionally animate fade bars
|
||
public void SetCurrentHP(int hp, bool animateFade = true)
|
||
{
|
||
if (isDead) return; // dead characters cannot change HP
|
||
int old = currentHP;
|
||
currentHP = Mathf.Clamp(hp, 0, maxHP);
|
||
if (currentHP <= 0) isDead = true;
|
||
UpdateHealthVisuals(old, animateFade);
|
||
|
||
// Trigger HP changed events
|
||
int delta = currentHP - old;
|
||
if (delta > 0)
|
||
{
|
||
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPHealed);
|
||
}
|
||
else if (delta < 0)
|
||
{
|
||
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPLost);
|
||
}
|
||
|
||
// Defeated event (fire once on transition >0 -> 0)
|
||
if (old > 0 && currentHP <= 0)
|
||
{
|
||
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnSelfDefeated);
|
||
}
|
||
|
||
// Evaluate HP-percentage based trigger transitions (entry/exit)
|
||
EvaluateHPPercentageTriggers(old);
|
||
}
|
||
|
||
// Internal HP modifier that does NOT apply heal multipliers (used by ReceiveHeal to avoid double rounding).
|
||
private void ModifyHPRaw(int delta, bool animateFade = true)
|
||
{
|
||
if (isDead && delta > 0)
|
||
{
|
||
// cannot heal a dead unit
|
||
return;
|
||
}
|
||
SetCurrentHP(currentHP + delta, animateFade);
|
||
}
|
||
|
||
public void ModifyHP(int delta, bool animateFade = true)
|
||
{
|
||
if (isDead && delta > 0)
|
||
{
|
||
// cannot heal a dead unit
|
||
return;
|
||
}
|
||
|
||
// Apply heal-received multipliers to all positive HP deltas so both ReceiveHeal() and direct ModifyHP(+)
|
||
// paths respect heal reduction / amplification buffs.
|
||
if (delta > 0)
|
||
{
|
||
float mult = GetTotalHealReceivedMultiplier();
|
||
if (!Mathf.Approximately(mult, 1f))
|
||
{
|
||
delta = Mathf.CeilToInt(delta * mult);
|
||
}
|
||
}
|
||
ModifyHPRaw(delta, animateFade);
|
||
}
|
||
|
||
public void SetMaxHP(int newMax, bool keepCurrentRatio = true)
|
||
{
|
||
if (newMax < 1) newMax = 1;
|
||
int oldMax = maxHP;
|
||
int oldHP = currentHP;
|
||
float ratio = maxHP > 0 ? (float)currentHP / maxHP : 1f;
|
||
maxHP = newMax;
|
||
if (keepCurrentRatio)
|
||
currentHP = Mathf.Clamp(Mathf.RoundToInt(ratio * maxHP), 0, maxHP);
|
||
else
|
||
currentHP = Mathf.Clamp(currentHP, 0, maxHP);
|
||
|
||
UpdateUIImmediate();
|
||
|
||
// Max HP change affects percentage triggers
|
||
EvaluateHPPercentageTriggers(oldHP);
|
||
}
|
||
|
||
public void SetCurrentMana(int mana, bool animateFade = true, bool flashVisuals = true)
|
||
{
|
||
if (isDead) return; // dead characters cannot change mana
|
||
int old = currentMana;
|
||
currentMana = Mathf.Clamp(mana, 0, maxMana);
|
||
UpdateManaVisuals(old, animateFade, flashVisuals);
|
||
// Only auto-cast when we actually reach full mana from below.
|
||
if (old < maxMana && currentMana >= maxMana)
|
||
{
|
||
TryCastOnFullMana();
|
||
}
|
||
|
||
// Trigger mana changed events
|
||
int delta = currentMana - old;
|
||
if (delta > 0)
|
||
{
|
||
if (_isTriggeringManaGained) return;
|
||
_isTriggeringManaGained = true;
|
||
try
|
||
{
|
||
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaGained);
|
||
}
|
||
finally
|
||
{
|
||
_isTriggeringManaGained = false;
|
||
}
|
||
}
|
||
else if (delta < 0)
|
||
{
|
||
if (_isTriggeringManaLost) return;
|
||
_isTriggeringManaLost = true;
|
||
try
|
||
{
|
||
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaLost);
|
||
}
|
||
finally
|
||
{
|
||
_isTriggeringManaLost = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
public void ModifyMana(int delta, bool animateFade = true, bool flashVisuals = true)
|
||
{
|
||
if (isDead && delta > 0)
|
||
{
|
||
// dead units cannot gain mana
|
||
return;
|
||
}
|
||
|
||
// If a skill tries to add mana to the caster while they're still at full mana during an OnManaFull cast,
|
||
// the increase would be clamped (wasted) and could also cause re-entrant casting. Buffer it and apply
|
||
// after the cast spends mana.
|
||
if (_isCastingOnManaFull && delta > 0 && currentMana >= maxMana)
|
||
{
|
||
_pendingManaGainAfterManaFullCast += delta;
|
||
return;
|
||
}
|
||
SetCurrentMana(currentMana + delta, animateFade, flashVisuals);
|
||
}
|
||
|
||
public void SetMaxMana(int newMax, bool keepCurrentRatio = true)
|
||
{
|
||
if (newMax < 1) newMax = 1;
|
||
int oldMax = maxMana;
|
||
int oldMana = currentMana;
|
||
float ratio = maxMana > 0 ? (float)currentMana / maxMana : 1f;
|
||
maxMana = newMax;
|
||
if (keepCurrentRatio)
|
||
currentMana = Mathf.Clamp(Mathf.RoundToInt(ratio * maxMana), 0, maxMana);
|
||
else
|
||
currentMana = Mathf.Clamp(currentMana, 0, maxMana);
|
||
|
||
UpdateUIImmediate();
|
||
// Mana doesn't currently have percentage triggers, but for consistency:
|
||
// EvaluateHPPercentageTriggers(currentHP); // Not needed for mana but keep in mind
|
||
}
|
||
|
||
private void EnsureAttackBaseInitialized()
|
||
{
|
||
if (_attackBaseInitialized) return;
|
||
_attackBaseInitialized = true;
|
||
_attackBaseUnbuffed = Mathf.Max(0, attack);
|
||
}
|
||
|
||
private float GetTotalAttackMultiplier()
|
||
{
|
||
float mult = 1f;
|
||
for (int i = 0; i < activeBuffs.Count; i++)
|
||
{
|
||
var b = activeBuffs[i];
|
||
if (b == null) continue;
|
||
mult *= b.attackMultiplier;
|
||
}
|
||
return mult;
|
||
}
|
||
|
||
private float GetTotalHealReceivedMultiplier()
|
||
{
|
||
float mult = 1f;
|
||
for (int i = 0; i < activeBuffs.Count; i++)
|
||
{
|
||
var b = activeBuffs[i];
|
||
if (b == null) continue;
|
||
mult *= b.healReceivedMultiplier;
|
||
}
|
||
return mult;
|
||
}
|
||
|
||
private void RecalculateAttackFromBuffs()
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
float mult = GetTotalAttackMultiplier();
|
||
|
||
int old = attack;
|
||
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult));
|
||
if (old != attack)
|
||
{
|
||
EvaluateAttackZeroTriggers();
|
||
EvaluateAttackBelowTriggers();
|
||
}
|
||
}
|
||
|
||
public void ModifyAttack(int delta)
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
_attackBaseUnbuffed = Mathf.Max(0, _attackBaseUnbuffed + delta);
|
||
RecalculateAttackFromBuffs();
|
||
}
|
||
|
||
public void SetAttack(int value)
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
_attackBaseUnbuffed = Mathf.Max(0, value);
|
||
RecalculateAttackFromBuffs();
|
||
}
|
||
|
||
private void UpdateHealthVisuals(int oldHP, bool animateFade)
|
||
{
|
||
// main bar should snap quickly to new value, while fade bar lerps towards it
|
||
if (healthImage != null)
|
||
{
|
||
float newFill = maxHP > 0 ? (float)currentHP / maxHP : 0f;
|
||
// snap main bar quickly for visual pop
|
||
healthImage.fillAmount = Mathf.MoveTowards(healthImage.fillAmount, newFill, 1f);
|
||
}
|
||
|
||
if (healthRateText != null)
|
||
healthRateText.text = $"{currentHP}/{maxHP}";
|
||
|
||
if (fadeHealthCoroutine != null) StopCoroutine(fadeHealthCoroutine);
|
||
if (fadeHealthImage != null)
|
||
{
|
||
float target = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
|
||
if (animateFade && Application.isPlaying)
|
||
fadeHealthCoroutine = StartCoroutine(FadeHealthCoroutine(target));
|
||
else
|
||
fadeHealthImage.fillAmount = target;
|
||
}
|
||
|
||
// HP change flash effects
|
||
if (hurtRedImage != null && Application.isPlaying)
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
if (currentHP < oldHP)
|
||
{
|
||
// Damage flash (Red)
|
||
if (!isDead)
|
||
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.hurtColor));
|
||
}
|
||
else if (currentHP > oldHP)
|
||
{
|
||
// Heal flash (Green)
|
||
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.healColor));
|
||
}
|
||
}
|
||
}
|
||
|
||
// if dead, trigger grayscale effect on character image
|
||
if (isDead && characterImage != null && Application.isPlaying)
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
// Documentation text normalized.
|
||
StartCoroutine(ui.AllyDeathGrayscale(characterImage));
|
||
}
|
||
}
|
||
}
|
||
|
||
private void UpdateManaVisuals(int oldMana, bool animateFade, bool flashVisuals = true)
|
||
{
|
||
// main bar snaps, fade bar lerps to new value for visual effect
|
||
if (manaImage != null)
|
||
{
|
||
float newFill = maxMana > 0 ? (float)currentMana / maxMana : 0f;
|
||
manaImage.fillAmount = Mathf.MoveTowards(manaImage.fillAmount, newFill, 1f);
|
||
}
|
||
|
||
if (fadeManaCoroutine != null) StopCoroutine(fadeManaCoroutine);
|
||
if (fadeManaImage != null)
|
||
{
|
||
float target = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
|
||
if (animateFade && Application.isPlaying)
|
||
fadeManaCoroutine = StartCoroutine(FadeManaCoroutine(target));
|
||
else
|
||
fadeManaImage.fillAmount = target;
|
||
}
|
||
|
||
// Mana recovery flash effect
|
||
if (flashVisuals && currentMana > oldMana && hurtRedImage != null && Application.isPlaying)
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.manaColor));
|
||
}
|
||
}
|
||
|
||
UpdateManaPercentLegacyText();
|
||
}
|
||
|
||
private void UpdateUIImmediate()
|
||
{
|
||
if (healthImage != null)
|
||
{
|
||
healthImage.fillAmount = maxHP > 0 ? (float)currentHP / maxHP : 0f;
|
||
}
|
||
if (fadeHealthImage != null)
|
||
{
|
||
// keep fade bar equal to main bar immediately
|
||
fadeHealthImage.fillAmount = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
|
||
}
|
||
if (manaImage != null)
|
||
{
|
||
manaImage.fillAmount = maxMana > 0 ? (float)currentMana / maxMana : 0f;
|
||
}
|
||
if (fadeManaImage != null)
|
||
{
|
||
fadeManaImage.fillAmount = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
|
||
}
|
||
if (healthRateText != null)
|
||
{
|
||
healthRateText.text = $"{currentHP}/{maxHP}";
|
||
}
|
||
|
||
UpdateManaPercentLegacyText();
|
||
}
|
||
|
||
private IEnumerator FadeHealthCoroutine(float targetFill)
|
||
{
|
||
if (fadeHealthImage == null) yield break;
|
||
float start = fadeHealthImage.fillAmount;
|
||
float duration = 0.6f;
|
||
float t = 0f;
|
||
while (t < duration)
|
||
{
|
||
t += Time.deltaTime;
|
||
fadeHealthImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
|
||
yield return null;
|
||
}
|
||
fadeHealthImage.fillAmount = targetFill;
|
||
}
|
||
|
||
private IEnumerator FadeManaCoroutine(float targetFill)
|
||
{
|
||
if (fadeManaImage == null) yield break;
|
||
float start = fadeManaImage.fillAmount;
|
||
float duration = 0.4f;
|
||
float t = 0f;
|
||
while (t < duration)
|
||
{
|
||
t += Time.deltaTime;
|
||
fadeManaImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
|
||
yield return null;
|
||
}
|
||
fadeManaImage.fillAmount = targetFill;
|
||
}
|
||
|
||
public void ReceiveDamage(float amount, GameObject source)
|
||
{
|
||
ReceiveDamageInternal(amount, source, true);
|
||
}
|
||
|
||
private void ReceiveDamageInternal(float amount, GameObject source, bool allowRedirect)
|
||
{
|
||
if (IsDead) return; // dead characters cannot be hit
|
||
if (allowRedirect && IsRedirectActive())
|
||
{
|
||
var redirector = s_nextDamageRedirector;
|
||
ClearRedirect();
|
||
if (redirector != null && redirector != this)
|
||
{
|
||
redirector.ReceiveDamageInternal(amount, source, false);
|
||
return;
|
||
}
|
||
// If redirector is self or invalid, consume redirect and continue to take damage normally.
|
||
}
|
||
|
||
// Self damage redirection: while active, redirect damage that would hit THIS ally to an adjacent ally.
|
||
// Do not redirect self-inflicted costs (source == this.gameObject) to avoid breaking "pay HP" skills.
|
||
if (allowRedirect && IsSelfDamageRedirectToAdjacentActive() && source != this.gameObject)
|
||
{
|
||
var adj = FindAdjacentRedirectTarget();
|
||
if (adj != null && adj != this)
|
||
{
|
||
adj.ReceiveDamageInternal(amount, source, false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
float effective = amount * (1f - damageResistance);
|
||
int delta = Mathf.CeilToInt(effective);
|
||
ModifyHP(-delta, true);
|
||
}
|
||
|
||
public void ReceiveHeal(float amount, GameObject source)
|
||
{
|
||
if (IsDead) return; // dead characters cannot be healed
|
||
float mult = GetTotalHealReceivedMultiplier();
|
||
int delta = Mathf.CeilToInt(amount * mult);
|
||
ModifyHPRaw(delta, true);
|
||
}
|
||
|
||
public void ApplyBuff(Buff buff, GameObject source)
|
||
{
|
||
if (buff == null) return;
|
||
activeBuffs.Add(buff);
|
||
// Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted.
|
||
if (buff.scoreMultiplier != 1f) scoreEfficiency *= buff.scoreMultiplier;
|
||
if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs();
|
||
// UI/visual could be added
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
private void TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger when)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (SkillBuilder.Instance == null) return;
|
||
|
||
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null) return;
|
||
|
||
// If equipped groups exist, iterate them first
|
||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||
{
|
||
foreach (var gid in so.equippedSkillGroupIDs)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = null;
|
||
for (int k = 0; k < so.skillGroups.Length; k++)
|
||
{
|
||
var g = so.skillGroups[k];
|
||
if (g != null && g.skillGroupID == gid) { group = g; break; }
|
||
}
|
||
if (group == null) continue;
|
||
foreach (var def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != when) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Fallback: check primary group
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null)
|
||
{
|
||
foreach (var def in fallbackGroup.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != when) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Final fallback: iterate availableSkills
|
||
if (so.availableSkills != null)
|
||
{
|
||
foreach (var def in so.availableSkills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != when) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void TryCastOnFullMana()
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (_isCastingOnManaFull) return;
|
||
if (currentMana < maxMana) return;
|
||
|
||
// Safety: avoid infinite loops if a cast immediately refills mana to full in the same frame.
|
||
if (_lastManaFullCastFrame == Time.frameCount) return;
|
||
_lastManaFullCastFrame = Time.frameCount;
|
||
|
||
if (SkillBuilder.Instance == null)
|
||
{
|
||
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will not attempt cast.");
|
||
return;
|
||
}
|
||
|
||
_isCastingOnManaFull = true;
|
||
_pendingManaGainAfterManaFullCast = 0;
|
||
try
|
||
{
|
||
// Attempt to cast primary skill configured in SO via SkillBuilder
|
||
CastSkill();
|
||
// Reset mana after casting via SetCurrentMana so "OnManaLost" triggers include this spend.
|
||
// (TryCastOnFullMana directly assigning currentMana would bypass mana-change events.)
|
||
SetCurrentMana(0, true, false);
|
||
}
|
||
finally
|
||
{
|
||
_isCastingOnManaFull = false;
|
||
}
|
||
|
||
// Apply any buffered mana gains now that mana has been spent.
|
||
if (_pendingManaGainAfterManaFullCast != 0)
|
||
{
|
||
int pending = _pendingManaGainAfterManaFullCast;
|
||
_pendingManaGainAfterManaFullCast = 0;
|
||
ModifyMana(pending, true, true);
|
||
}
|
||
}
|
||
|
||
private void CastSkill()
|
||
{
|
||
// Use SkillBuilder to invoke the primary skill defined in the AllyHero_SO for this slot
|
||
if (SkillBuilder.Instance == null)
|
||
{
|
||
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: SkillBuilder.Instance is null.");
|
||
return;
|
||
}
|
||
|
||
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null)
|
||
{
|
||
// Can't locate SO for this slot; attempt best-effort: call UsePrimarySkillForSlot which will log details
|
||
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1}: AllyHero_SO not found for slot. Falling back to UsePrimarySkillForSlot.");
|
||
SkillBuilder.Instance.UsePrimarySkillForSlot(slotIndex, -1f, null);
|
||
return;
|
||
}
|
||
|
||
bool anyTriggered = false;
|
||
|
||
// First: equipped groups
|
||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||
{
|
||
foreach (int gid in so.equippedSkillGroupIDs)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = null;
|
||
for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } }
|
||
if (group == null) continue;
|
||
foreach (var skill in group.skills)
|
||
{
|
||
if (skill == null) continue;
|
||
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId} (OnManaFull)");
|
||
anyTriggered = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second: primary group
|
||
if (!anyTriggered)
|
||
{
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null)
|
||
{
|
||
foreach (var skill in fallbackGroup.skills)
|
||
{
|
||
if (skill == null) continue;
|
||
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from primary group: {skill.skillId} (OnManaFull)");
|
||
anyTriggered = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Third: availableSkills / primary skill fallback
|
||
if (!anyTriggered)
|
||
{
|
||
// try primary skill specifically
|
||
var def = so.GetPrimarySkill();
|
||
if (def != null && def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull)
|
||
{
|
||
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull.");
|
||
anyTriggered = true;
|
||
}
|
||
else if (so.availableSkills != null)
|
||
{
|
||
foreach (var skill in so.availableSkills)
|
||
{
|
||
if (skill == null) continue;
|
||
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
|
||
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast available skill: {skill.skillId} (OnManaFull)");
|
||
anyTriggered = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!anyTriggered)
|
||
{
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1}: no skills configured for OnManaFull.");
|
||
}
|
||
}
|
||
|
||
private void CastSkillGroup(SkillGroup group, int slotIndex)
|
||
{
|
||
if (group == null) return;
|
||
// For auto-casting triggered by ManaFull, only cast skills that are configured to trigger on ManaFull.
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var skill = group.skills[i];
|
||
if (skill == null) continue;
|
||
// Only cast skills whose triggerCondition matches OnManaFull when this method is invoked from auto-cast path
|
||
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull)
|
||
{
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} skill '{skill.skillId}' skipped: triggerCondition={skill.triggerCondition}.");
|
||
continue;
|
||
}
|
||
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
|
||
LogVerbose($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId}");
|
||
}
|
||
}
|
||
|
||
public void TriggerOnManaFull()
|
||
{
|
||
if (currentMana >= maxMana)
|
||
{
|
||
TryCastOnFullMana();
|
||
}
|
||
}
|
||
|
||
[ContextMenu("Test Fill Mana")]
|
||
private void TestFillMana()
|
||
{
|
||
SetCurrentMana(maxMana, true);
|
||
}
|
||
|
||
|
||
private Dictionary<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;
|
||
return vars;
|
||
}
|
||
// New: Evaluate HP-percent triggers and handle enter/leave transitions
|
||
private void EvaluateHPPercentageTriggers(int oldHP)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (SkillBuilder.Instance == null) return;
|
||
|
||
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null) return;
|
||
|
||
float oldPct = (maxHP > 0) ? (float)oldHP / (float)maxHP : 0f;
|
||
float currPct = (maxHP > 0) ? (float)currentHP / (float)maxHP : 0f;
|
||
|
||
// Collect candidate skills (same priority order as other triggers)
|
||
var defs = _tmpSkillDefs;
|
||
defs.Clear();
|
||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||
{
|
||
foreach (var gid in so.equippedSkillGroupIDs)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = null;
|
||
for (int k = 0; k < so.skillGroups.Length; k++)
|
||
{
|
||
var g = so.skillGroups[k];
|
||
if (g != null && g.skillGroupID == gid) { group = g; break; }
|
||
}
|
||
if (group == null || group.skills == null) continue;
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var def = group.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null && fallbackGroup.skills != null)
|
||
{
|
||
for (int i = 0; i < fallbackGroup.skills.Length; i++)
|
||
{
|
||
var def = fallbackGroup.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
else if (so.availableSkills != null)
|
||
{
|
||
for (int i = 0; i < so.availableSkills.Length; i++)
|
||
{
|
||
var def = so.availableSkills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
|
||
var vars = BuildFormulaVars(so);
|
||
|
||
// Select a single "best" Above/Below skill to avoid stacking when multiple thresholds are satisfied.
|
||
string selectedAbove = null;
|
||
float selectedAboveThreshold = float.MinValue;
|
||
string selectedBelow = null;
|
||
float selectedBelowThreshold = float.MaxValue;
|
||
for (int i = 0; i < defs.Count; i++)
|
||
{
|
||
var def = defs[i];
|
||
if (def == null) continue;
|
||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent)
|
||
{
|
||
float threshold = def.hpTriggerPercent;
|
||
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
|
||
{
|
||
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
|
||
}
|
||
threshold = Mathf.Clamp01(threshold);
|
||
|
||
if (currPct >= threshold && threshold >= selectedAboveThreshold)
|
||
{
|
||
selectedAboveThreshold = threshold;
|
||
selectedAbove = def.skillId;
|
||
}
|
||
}
|
||
else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent)
|
||
{
|
||
float threshold = def.hpTriggerPercent;
|
||
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
|
||
{
|
||
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
|
||
}
|
||
threshold = Mathf.Clamp01(threshold);
|
||
|
||
if (currPct <= threshold && threshold <= selectedBelowThreshold)
|
||
{
|
||
selectedBelowThreshold = threshold;
|
||
selectedBelow = def.skillId;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < defs.Count; i++)
|
||
{
|
||
var def = defs[i];
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnHPAbovePercent && def.triggerCondition != SkillDefinition.SkillTrigger.OnHPBelowPercent)
|
||
continue;
|
||
|
||
float threshold = def.hpTriggerPercent;
|
||
if (!string.IsNullOrWhiteSpace(def.hpTriggerPercentFormula))
|
||
{
|
||
if (SkillDefinition.TryEvaluateFormula(def.hpTriggerPercentFormula, vars, out float th)) threshold = th;
|
||
}
|
||
threshold = Mathf.Clamp01(threshold);
|
||
|
||
bool wasActive = _activeHpPercentSkills.Contains(def.skillId);
|
||
bool nowActive = false;
|
||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent)
|
||
nowActive = (selectedAbove != null && def.skillId == selectedAbove) && currPct >= threshold;
|
||
else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent)
|
||
nowActive = (selectedBelow != null && def.skillId == selectedBelow) && currPct <= threshold;
|
||
|
||
// Evaluate formula into amount using similar variables as SkillBuilder
|
||
float amount = 0f;
|
||
|
||
if (!string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||
amount = fresult;
|
||
}
|
||
|
||
// Decide if this effect type is reversible (state-based)
|
||
bool isReversible = IsEffectReversible(def.effectType);
|
||
|
||
if (!isReversible)
|
||
{
|
||
// For non-reversible effects (like Damage or instant Heal), we don't track state
|
||
// Just trigger it once when the condition is first met
|
||
if (!wasActive && nowActive)
|
||
{
|
||
_activeHpPercentSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
_activeHpPercentSkills.Remove(def.skillId);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!wasActive && nowActive)
|
||
{
|
||
// ENTER condition: Apply effect and record amount
|
||
_activeHpPercentSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
_appliedHpPercentEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
// LEAVE condition: Revert effect using recorded amount
|
||
_activeHpPercentSkills.Remove(def.skillId);
|
||
if (_appliedHpPercentEffects.TryGetValue(def.skillId, out var applied))
|
||
{
|
||
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
|
||
_appliedHpPercentEffects.Remove(def.skillId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void EvaluateAttackZeroTriggers()
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (SkillBuilder.Instance == null) return;
|
||
|
||
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null) return;
|
||
|
||
bool nowActive = attack <= 0;
|
||
|
||
var defs = _tmpSkillDefs;
|
||
defs.Clear();
|
||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||
{
|
||
foreach (var gid in so.equippedSkillGroupIDs)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = null;
|
||
for (int k = 0; k < so.skillGroups.Length; k++)
|
||
{
|
||
var g = so.skillGroups[k];
|
||
if (g != null && g.skillGroupID == gid) { group = g; break; }
|
||
}
|
||
if (group == null || group.skills == null) continue;
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var def = group.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null && fallbackGroup.skills != null)
|
||
{
|
||
for (int i = 0; i < fallbackGroup.skills.Length; i++)
|
||
{
|
||
var def = fallbackGroup.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
else if (so.availableSkills != null)
|
||
{
|
||
for (int i = 0; i < so.availableSkills.Length; i++)
|
||
{
|
||
var def = so.availableSkills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
|
||
var vars = BuildFormulaVars(so);
|
||
|
||
for (int i = 0; i < defs.Count; i++)
|
||
{
|
||
var def = defs[i];
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackZero) continue;
|
||
|
||
bool wasActive = _activeAttackZeroSkills.Contains(def.skillId);
|
||
|
||
// Evaluate formula into amount using similar variables as SkillBuilder
|
||
float amount = 0f;
|
||
|
||
if (!string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||
amount = 0f;
|
||
else
|
||
amount = fresult;
|
||
}
|
||
|
||
bool isReversible = IsEffectReversible(def.effectType);
|
||
if (!isReversible)
|
||
{
|
||
if (!wasActive && nowActive)
|
||
{
|
||
_activeAttackZeroSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
_activeAttackZeroSkills.Remove(def.skillId);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!wasActive && nowActive)
|
||
{
|
||
_activeAttackZeroSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
_appliedAttackZeroEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
_activeAttackZeroSkills.Remove(def.skillId);
|
||
if (_appliedAttackZeroEffects.TryGetValue(def.skillId, out var applied))
|
||
{
|
||
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
|
||
_appliedAttackZeroEffects.Remove(def.skillId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void EvaluateAttackBelowTriggers()
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (SkillBuilder.Instance == null) return;
|
||
|
||
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null) return;
|
||
|
||
int currAttack = attack;
|
||
|
||
var defs = _tmpSkillDefs;
|
||
defs.Clear();
|
||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||
{
|
||
foreach (var gid in so.equippedSkillGroupIDs)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = null;
|
||
for (int k = 0; k < so.skillGroups.Length; k++)
|
||
{
|
||
var g = so.skillGroups[k];
|
||
if (g != null && g.skillGroupID == gid) { group = g; break; }
|
||
}
|
||
if (group == null || group.skills == null) continue;
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var def = group.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null && fallbackGroup.skills != null)
|
||
{
|
||
for (int i = 0; i < fallbackGroup.skills.Length; i++)
|
||
{
|
||
var def = fallbackGroup.skills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
else if (so.availableSkills != null)
|
||
{
|
||
for (int i = 0; i < so.availableSkills.Length; i++)
|
||
{
|
||
var def = so.availableSkills[i];
|
||
if (def != null) defs.Add(def);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pick the most restrictive threshold that is still satisfied (smallest attackTriggerValue such that attack < value).
|
||
string selected = null;
|
||
int selectedThreshold = int.MaxValue;
|
||
for (int i = 0; i < defs.Count; i++)
|
||
{
|
||
var def = defs[i];
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue;
|
||
|
||
int thr = def.attackTriggerValue;
|
||
if (thr < 0) thr = 0;
|
||
|
||
if (currAttack < thr && thr <= selectedThreshold)
|
||
{
|
||
selectedThreshold = thr;
|
||
selected = def.skillId;
|
||
}
|
||
}
|
||
|
||
var vars = BuildFormulaVars(so);
|
||
|
||
for (int i = 0; i < defs.Count; i++)
|
||
{
|
||
var def = defs[i];
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAttackBelowValue) continue;
|
||
|
||
int thr = def.attackTriggerValue;
|
||
if (thr < 0) thr = 0;
|
||
|
||
bool wasActive = _activeAttackBelowSkills.Contains(def.skillId);
|
||
bool nowActive = (selected != null && def.skillId == selected) && (currAttack < thr);
|
||
|
||
float amount = 0f;
|
||
if (!string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
if (SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||
amount = fresult;
|
||
}
|
||
|
||
bool isReversible = IsEffectReversible(def.effectType);
|
||
if (!isReversible)
|
||
{
|
||
if (!wasActive && nowActive)
|
||
{
|
||
_activeAttackBelowSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
_activeAttackBelowSkills.Remove(def.skillId);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!wasActive && nowActive)
|
||
{
|
||
_activeAttackBelowSkills.Add(def.skillId);
|
||
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
|
||
_appliedAttackBelowEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
|
||
}
|
||
else if (wasActive && !nowActive)
|
||
{
|
||
_activeAttackBelowSkills.Remove(def.skillId);
|
||
if (_appliedAttackBelowEffects.TryGetValue(def.skillId, out var applied))
|
||
{
|
||
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
|
||
_appliedAttackBelowEffects.Remove(def.skillId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public static void ActivateNextDamageRedirect(AllyCombatant redirector, float duration = 0f)
|
||
{
|
||
if (redirector == null) return;
|
||
s_nextDamageRedirector = redirector;
|
||
if (duration > 0f && Application.isPlaying)
|
||
s_redirectExpireTime = Time.time + duration;
|
||
else
|
||
s_redirectExpireTime = -1f;
|
||
}
|
||
|
||
private static bool IsRedirectActive()
|
||
{
|
||
if (s_nextDamageRedirector == null) return false;
|
||
if (Application.isPlaying && s_redirectExpireTime > 0f && Time.time > s_redirectExpireTime)
|
||
{
|
||
ClearRedirect();
|
||
return false;
|
||
}
|
||
if (s_nextDamageRedirector != null && s_nextDamageRedirector.IsDead)
|
||
{
|
||
ClearRedirect();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private static void ClearRedirect()
|
||
{
|
||
s_nextDamageRedirector = null;
|
||
s_redirectExpireTime = -1f;
|
||
}
|
||
|
||
public void ActivateSelfDamageRedirectToAdjacent(float duration)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (duration <= 0f) duration = 0.01f;
|
||
_selfDamageRedirectToAdjacentExpireTime = Time.time + duration;
|
||
}
|
||
|
||
private bool IsSelfDamageRedirectToAdjacentActive()
|
||
{
|
||
if (!Application.isPlaying) return false;
|
||
if (_selfDamageRedirectToAdjacentExpireTime <= 0f) return false;
|
||
if (Time.time > _selfDamageRedirectToAdjacentExpireTime)
|
||
{
|
||
_selfDamageRedirectToAdjacentExpireTime = -1f;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private AllyCombatant FindAdjacentRedirectTarget()
|
||
{
|
||
// Choose an adjacent ally (left first, then right) that is alive and active.
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
int[] adj = ui.GetAdjacentAllyIndices(slotIndex);
|
||
for (int i = 0; i < adj.Length; i++)
|
||
{
|
||
int idx = adj[i];
|
||
GameObject go = ui.GetAllyObjectBySlot(idx) ?? GameObject.Find($"ally_0{idx + 1}");
|
||
if (go == null) continue;
|
||
var ally = go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true);
|
||
if (ally == null) continue;
|
||
if (ally == this) continue;
|
||
if (ally.IsDead) continue;
|
||
if (!ally.gameObject.activeInHierarchy) continue;
|
||
return ally;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
int left = slotIndex - 1;
|
||
int right = slotIndex + 1;
|
||
if (left >= 0)
|
||
{
|
||
var go = GameObject.Find($"ally_0{left + 1}");
|
||
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
|
||
if (ally != null && ally != this && !ally.IsDead && ally.gameObject.activeInHierarchy) return ally;
|
||
}
|
||
if (right <= 4)
|
||
{
|
||
var go = GameObject.Find($"ally_0{right + 1}");
|
||
var ally = go != null ? (go.GetComponent<AllyCombatant>() ?? go.GetComponentInChildren<AllyCombatant>(true)) : null;
|
||
if (ally != null && ally != this && !ally.IsDead && ally.gameObject.activeInHierarchy) return ally;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private bool IsEffectReversible(EffectType type)
|
||
{
|
||
switch (type)
|
||
{
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
case EffectType.ScoreMultiplier:
|
||
case EffectType.AddScore:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void ApplyInverseEffect(Selector selector, EffectType type, float amount)
|
||
{
|
||
if (EffectSystem.Instance == null) return;
|
||
|
||
switch (type)
|
||
{
|
||
case EffectType.IncreaseAttack:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseAttack, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.DecreaseAttack:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseAttack, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.IncreaseMaxHP:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxHP, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.DecreaseMaxHP:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxHP, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.IncreaseMaxMana:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxMana, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.DecreaseMaxMana:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxMana, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseScoreEfficiency, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseScoreEfficiency, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.IncreaseDamageResistance:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseDamageResistance, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.DecreaseDamageResistance:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseDamageResistance, amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.ScoreMultiplier:
|
||
if (Mathf.Abs(amount) > 0.0001f)
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, 1f / amount, 0f, this.gameObject, null);
|
||
break;
|
||
case EffectType.AddScore:
|
||
EffectSystem.Instance.ApplyEffect(selector, EffectType.AddScore, -amount, 0f, this.gameObject, null);
|
||
break;
|
||
default:
|
||
// Fallback: apply same type with negative amount if it's likely a numeric stat
|
||
EffectSystem.Instance.ApplyEffect(selector, type, -amount, 0f, this.gameObject, null);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|