2377 lines
113 KiB
C#
2377 lines
113 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
/// <summary>
|
||
/// Documentation text normalized.
|
||
/// Documentation text normalized.
|
||
/// Documentation text normalized.
|
||
/// Documentation text normalized.
|
||
///
|
||
/// Documentation text normalized.
|
||
/// SkillBuilder.Instance.ExecuteSkill("Fireball", EffectType.DamageSingleEnemy, 120f, Selector.CurrentEnemies, caster, target);
|
||
/// Documentation text normalized.
|
||
/// </summary>
|
||
public class SkillBuilder : MonoBehaviour
|
||
{
|
||
public static SkillBuilder Instance { get; private set; }
|
||
|
||
// Dedup UI feed so one small-skill (group) that uses multiple SkillDefinitions only prints once per frame.
|
||
private static readonly Dictionary<long, int> s_lastSkillFeedFrameByKey = new Dictionary<long, int>(128);
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance == null) Instance = this;
|
||
else
|
||
{
|
||
Destroy(gameObject);
|
||
return;
|
||
}
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
// Prewarm expensive Resources.LoadAll<AllyHero_SO>("") so first note hit doesn't hitch.
|
||
// This keeps behavior the same, just moves the cost into loading time.
|
||
try
|
||
{
|
||
PrewarmAllyHeroSOIndex();
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
Debug.LogWarning($"[SkillBuilder] PrewarmAllyHeroSOIndex failed: {ex}");
|
||
}
|
||
|
||
// Reset in-game skill trigger feed each play session.
|
||
try { SkillTriggerFeedUI.Clear(); } catch { }
|
||
try { s_lastSkillFeedFrameByKey.Clear(); } catch { }
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public float defaultDamage = 100f;
|
||
public float defaultHeal = 80f;
|
||
public float defaultDuration = 5f;
|
||
public float defaultTickInterval = 1f;
|
||
|
||
[Header("Shared note-hit auto-skill settings")]
|
||
// These defaults remain as a global fallback but the preferred source is per-level fields in AllyHero_SO
|
||
[Tooltip("(Fallback) Mana gained on Good judge if SO level value not present")] public int manaGainGood = 1;
|
||
[Tooltip("(Fallback) Mana gained on Great judge if SO level value not present")] public int manaGainGreat = 2;
|
||
[Tooltip("(Fallback) Mana gained on Perfect judge if SO level value not present")] public int manaGainPerfect = 3;
|
||
[Tooltip("(Fallback) Mana gained on Miss judge if SO level value not present")] public int manaGainOnMiss = 5;
|
||
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Good hits if SO level value not present")] public float damageMultiplierGood = 0.5f;
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Great hits if SO level value not present")] public float damageMultiplierGreat = 0.75f;
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Perfect hits if SO level value not present")] public float damageMultiplierPerfect = 1f;
|
||
|
||
[Tooltip("(Fallback) Base HP loss on Miss will be (missHpLossBase * (1 - damageResistance)) if SO level value not present")] public float missHpLossBase = 10f;
|
||
|
||
// --------- Caches to avoid first-hit hitch (Resources.LoadAll) ---------
|
||
private AllyHero_SO[] _allAllyHeroSOs;
|
||
private Dictionary<int, AllyHero_SO> _allyHeroSoById;
|
||
private Dictionary<int, AllyHero_SO> _allyHeroSoBySlotCache;
|
||
|
||
// Reusable buffers to reduce allocations during gameplay
|
||
private readonly Dictionary<string, float> _varsBuffer = new Dictionary<string, float>(16);
|
||
private readonly List<string> _tmpNoteIdRemoval = new List<string>(16);
|
||
private readonly Dictionary<string, float> _lastSkillTriggerTime = new Dictionary<string, float>(256);
|
||
private readonly Dictionary<string, Coroutine> _refreshOnlyTimedEffectCoroutines = new Dictionary<string, Coroutine>(32);
|
||
private readonly Dictionary<string, RefreshOnlyTimedState> _refreshOnlyTimedEffectStates = new Dictionary<string, RefreshOnlyTimedState>(32);
|
||
private readonly Dictionary<string, Coroutine> _refreshOnlyOverTimeCoroutines = new Dictionary<string, Coroutine>(16);
|
||
private int _cachedAlliesFrame = -1;
|
||
private readonly List<GameObject> _cachedAllies = new List<GameObject>(8);
|
||
|
||
private static readonly HashSet<string> s_refreshOnlyTimedSkillIds = new HashSet<string>
|
||
{
|
||
// Lock
|
||
"44305_2", // Lock_TurnTables (attack up, timed)
|
||
"44306_2", // Lock_Mine (score efficiency up, timed)
|
||
"44308", // Lock_Accomplice (damage resistance up, timed)
|
||
|
||
// MocaiLi
|
||
"44202", // HoldLine (damage resistance up, timed)
|
||
"44203", // LockOn (enemy damage resistance down, timed)
|
||
"44204", // Heartbeat (enemy attack down, timed)
|
||
"44208", // BloodInspire (score efficiency up, timed)
|
||
|
||
// Winnie
|
||
"44403", // Inspiration (score efficiency up, timed)
|
||
"44404", // EasyTask (score efficiency up, timed)
|
||
"44408", // ContributeCreate (max HP up, timed)
|
||
};
|
||
|
||
private static readonly HashSet<string> s_refreshOnlyOverTimeSkillIds = new HashSet<string>
|
||
{
|
||
// MocaiLi LastHope pair
|
||
"44210_1", // Heal over time
|
||
"44210_2", // Mana over time (can be negative)
|
||
};
|
||
|
||
private sealed class RefreshOnlyTimedState
|
||
{
|
||
public EffectType effectType;
|
||
public float floatDelta;
|
||
public int intDelta;
|
||
public string budeffIconId;
|
||
public int budeffSlotIndex = -1;
|
||
public int budeffEnemyInstanceId;
|
||
}
|
||
|
||
private void PrewarmAllyHeroSOIndex()
|
||
{
|
||
if (_allAllyHeroSOs != null && _allAllyHeroSOs.Length > 0) return;
|
||
|
||
_allAllyHeroSOs = Resources.LoadAll<AllyHero_SO>("");
|
||
_allyHeroSoById = new Dictionary<int, AllyHero_SO>(_allAllyHeroSOs != null ? _allAllyHeroSOs.Length : 0);
|
||
_allyHeroSoBySlotCache = new Dictionary<int, AllyHero_SO>(8);
|
||
|
||
if (_allAllyHeroSOs != null)
|
||
{
|
||
foreach (var so in _allAllyHeroSOs)
|
||
{
|
||
if (so == null) continue;
|
||
if (so.ally_heroID <= 0) continue;
|
||
if (!_allyHeroSoById.ContainsKey(so.ally_heroID))
|
||
_allyHeroSoById.Add(so.ally_heroID, so);
|
||
}
|
||
}
|
||
|
||
if (GameConfig.verboseLogs)
|
||
{
|
||
LogVerbose($"[SkillBuilder] PrewarmAllyHeroSOIndex: loaded {_allAllyHeroSOs?.Length ?? 0} AllyHero_SO assets, indexed {_allyHeroSoById.Count}");
|
||
}
|
||
}
|
||
|
||
// Placeholder: if you want to route damage through EffectSystem later, use this.
|
||
public void DealDamageFromAllyToEnemies(int slotIndex, float amount)
|
||
{
|
||
// Resolve caster GameObject and route damage to EffectSystem so enemies receive damage with resistances etc.
|
||
var caster = GetAllyObjectBySlot(slotIndex);
|
||
if (global::EffectSystem.Instance == null)
|
||
{
|
||
Debug.LogWarning($"DealDamageFromAllyToEnemies: EffectSystem.Instance is null. slot={slotIndex} amount={amount}");
|
||
return;
|
||
}
|
||
// Apply as single-instance damage to current enemies; EffectSystem will pick first applicable enemy for DamageSingleEnemy
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null);
|
||
LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem");
|
||
}
|
||
|
||
private void ApplySharedOnNoteHit(int trackIndex, string judgeResult)
|
||
{
|
||
if (trackIndex < 0) return;
|
||
var allyGO = GetAllyObjectBySlot(trackIndex);
|
||
if (allyGO == null) return;
|
||
var ally = allyGO.GetComponent<AllyCombatant>();
|
||
if (ally != null && ally.IsDead) return; // dead allies should not gain mana / take miss damage / deal note-hit damage
|
||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||
// try to obtain per-level params from SO if present
|
||
AllyHero_SO.AllyLevelInfo levelInfo = so != null ? (so.GetEffectiveLevelForCurrentEXP()) : null;
|
||
|
||
int manaGain = 0;
|
||
float damageMult = 0f;
|
||
if (levelInfo != null)
|
||
{
|
||
switch (judgeResult)
|
||
{
|
||
case "Perfect": manaGain = levelInfo.manaGainPerfect; damageMult = levelInfo.damageMultiplierPerfect; break;
|
||
case "Great": manaGain = levelInfo.manaGainGreat; damageMult = levelInfo.damageMultiplierGreat; break;
|
||
case "Good": manaGain = levelInfo.manaGainGood; damageMult = levelInfo.damageMultiplierGood; break;
|
||
default: manaGain = levelInfo.manaGainOnMiss; break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
switch (judgeResult)
|
||
{
|
||
case "Perfect": manaGain = manaGainPerfect; damageMult = damageMultiplierPerfect; break;
|
||
case "Great": manaGain = manaGainGreat; damageMult = damageMultiplierGreat; break;
|
||
case "Good": manaGain = manaGainGood; damageMult = damageMultiplierGood; break;
|
||
default: manaGain = manaGainOnMiss; break;
|
||
}
|
||
}
|
||
|
||
if (ally != null && manaGain != 0)
|
||
{
|
||
ally.ModifyMana(manaGain, true, false);
|
||
LogVerbose($"[SkillBuilder] Applied mana gain {manaGain} to slot {trackIndex} due to judge {judgeResult}");
|
||
}
|
||
|
||
if (judgeResult == "Miss")
|
||
{
|
||
if (ally != null)
|
||
{
|
||
// 如果所有敌人都已被消灭,Miss 不再扣血
|
||
if (global::EffectSystem.Instance != null && global::EffectSystem.Instance.AreAllEnemiesDead())
|
||
{
|
||
LogVerbose($"[SkillBuilder] All enemies dead -> Skip miss HP loss for slot {trackIndex}");
|
||
return;
|
||
}
|
||
|
||
// Log Miss event to feed (since HP loss will occur)
|
||
string mName = "Unknown";
|
||
if (so != null && !string.IsNullOrEmpty(so.ally_heroName)) mName = so.ally_heroName;
|
||
else if (ally != null) mName = ally.name; // fallback to GameObject name
|
||
SkillTriggerFeedUI.PushMiss(mName);
|
||
|
||
float missBase = (levelInfo != null) ? levelInfo.missHpLossBase : missHpLossBase;
|
||
|
||
// 播放敌人攻击特效,并将伤害逻辑延迟到特效到达时执行
|
||
bool effectStarted = false;
|
||
if (GfxController.Instance != null)
|
||
{
|
||
effectStarted = GfxController.Instance.PlayEnemyAttackOnMiss(trackIndex, missBase, ally);
|
||
}
|
||
|
||
if (!effectStarted)
|
||
{
|
||
// 如果特效启动失败或 GfxController 不存在,则立即扣血作为保底,并触发闪红
|
||
ally.ReceiveDamage(missBase, null);
|
||
if (teamUIController.Instance != null)
|
||
{
|
||
teamUIController.Instance.TriggerAllyHurtFlash(trackIndex);
|
||
}
|
||
LogVerbose($"[SkillBuilder] Gfx failed or missing. Applied immediate miss HP loss for slot {trackIndex}");
|
||
}
|
||
else
|
||
{
|
||
LogVerbose($"[SkillBuilder] Miss occurred for slot {trackIndex}. Damage {missBase} queued via GfxController.");
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (damageMult > 0f)
|
||
{
|
||
int baseAtk = ally != null ? ally.attack : GetAllyBaseAttack(so);
|
||
// damage should scale with (1 - damageResistance) of enemies when applied; here we pass raw amount = baseAtk * damageMult
|
||
float dmg = baseAtk * damageMult;
|
||
DealDamageFromAllyToEnemies(trackIndex, dmg);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void ExecuteSkill(string skillName, EffectType effectType, float amount, Selector selector, GameObject caster, GameObject specificTarget = null, float duration = 0f, float tickInterval = 1f)
|
||
{
|
||
if (global::EffectSystem.Instance == null)
|
||
{
|
||
Debug.LogWarning($"ExecuteSkill failed: EffectSystem.Instance is null");
|
||
return;
|
||
}
|
||
// basic logging
|
||
LogVerbose($"[SkillBuilder] ExecuteSkill: {skillName} type={effectType} amount={amount} selector={selector} caster={(caster?caster.name:"null")} target={(specificTarget?specificTarget.name:"null")} duration={duration}");
|
||
|
||
// additional debug: show when caster is null which will make Selector.Self produce no targets
|
||
if (selector == Selector.Self && caster == null)
|
||
{
|
||
Debug.LogWarning($"[SkillBuilder] ExecuteSkill: selector==Self but caster is null for skill {skillName}. Effect will have no targets.");
|
||
}
|
||
|
||
// call EffectSystem using GameObject API (keeps compatibility with existing EffectSystem)
|
||
global::EffectSystem.Instance.ApplyEffect(selector, effectType, amount, duration, caster, specificTarget, tickInterval);
|
||
}
|
||
|
||
// New overload: execute by slot index (no GameObject required externally)
|
||
public void ExecuteSkillBySlot(string skillName, EffectType effectType, float amount, Selector selector, int casterSlotIndex = -1, int specificTargetSlot = -1, float duration = 0f, float tickInterval = 1f)
|
||
{
|
||
GameObject casterGO = casterSlotIndex >= 0 ? GetAllyObjectBySlot(casterSlotIndex) : null;
|
||
GameObject targetGO = specificTargetSlot >= 0 ? GetAllyObjectBySlot(specificTargetSlot) : null;
|
||
ExecuteSkill(skillName, effectType, amount, selector, casterGO, targetGO, duration, tickInterval);
|
||
}
|
||
|
||
|
||
private void LogVerbose(string message)
|
||
{
|
||
if (GameConfig.verboseLogs) Debug.Log(message);
|
||
}
|
||
|
||
private List<GameObject> GetAlliesCached()
|
||
{
|
||
if (_cachedAlliesFrame == Time.frameCount) return _cachedAllies;
|
||
_cachedAlliesFrame = Time.frameCount;
|
||
_cachedAllies.Clear();
|
||
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
int count = ui.allySlotIds != null ? ui.allySlotIds.Count : 5;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var go = ui.GetAllyObjectBySlot(i);
|
||
if (go != null && !_cachedAllies.Contains(go)) _cachedAllies.Add(go);
|
||
}
|
||
}
|
||
|
||
if (_cachedAllies.Count == 0)
|
||
{
|
||
for (int i = 1; i <= 5; i++)
|
||
{
|
||
var go = GameObject.Find($"ally_0{i}");
|
||
if (go != null && !_cachedAllies.Contains(go)) _cachedAllies.Add(go);
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
var tagged = GameObject.FindGameObjectsWithTag("Ally");
|
||
foreach (var g in tagged)
|
||
{
|
||
if (g != null && !_cachedAllies.Contains(g)) _cachedAllies.Add(g);
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
return _cachedAllies;
|
||
}
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public List<GameObject> ResolveTargetsLocal(Selector selector, GameObject source = null, GameObject specificTarget = null)
|
||
{
|
||
List<GameObject> list = new List<GameObject>();
|
||
// If caller provided a specificTarget, honor it as the single target regardless of selector.
|
||
if (specificTarget != null)
|
||
{
|
||
list.Add(specificTarget);
|
||
return list;
|
||
}
|
||
switch (selector)
|
||
{
|
||
case Selector.Self:
|
||
if (source != null) list.Add(source);
|
||
break;
|
||
case Selector.AllAllies:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
break;
|
||
case Selector.AllAlliesExceptSelf:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
if (source != null) list.RemoveAll(g => g == null || g == source);
|
||
break;
|
||
case Selector.AdjacentAllies:
|
||
if (source == null) break;
|
||
int slotIndex = -1;
|
||
var ac = source.GetComponent<AllyCombatant>();
|
||
if (ac != null) slotIndex = ac.slotIndex;
|
||
else
|
||
{
|
||
var n = source.name;
|
||
for (int i = 1; i <= 5; i++) if (n.Contains($"ally_0{i}")) { slotIndex = i - 1; break; }
|
||
}
|
||
|
||
var ui = teamUIController.Instance;
|
||
// Additional attempt: if source isn't AllyCombatant and name parse failed, try to match source to UI slots (or child of slot object)
|
||
if (slotIndex < 0 && ui != null)
|
||
{
|
||
for (int i = 0; i < 5; i++)
|
||
{
|
||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||
if (slotObj == null) continue;
|
||
if (slotObj == source) { slotIndex = i; break; }
|
||
// if source is a child of the slot object
|
||
if (source.transform.IsChildOf(slotObj.transform)) { slotIndex = i; break; }
|
||
}
|
||
}
|
||
|
||
if (slotIndex >= 0)
|
||
{
|
||
if (ui != null)
|
||
{
|
||
var adj = ui.GetAdjacentAllyIndices(slotIndex);
|
||
foreach (var idx in adj)
|
||
{
|
||
var go = ui.GetAllyObjectBySlot(idx);
|
||
if (go != null) list.Add(go);
|
||
else { var named = GameObject.Find($"ally_0{idx + 1}"); if (named != null) list.Add(named); }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
int left = slotIndex - 1, right = slotIndex + 1;
|
||
if (left >= 0) { var g = GameObject.Find($"ally_0{left + 1}"); if (g != null) list.Add(g); }
|
||
if (right <= 4) { var g = GameObject.Find($"ally_0{right + 1}"); if (g != null) list.Add(g); }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[SkillBuilder] ResolveTargetsLocal: could not determine slot index for AdjacentAllies for source=" + (source?source.name:"null"));
|
||
}
|
||
break;
|
||
case Selector.CurrentEnemies:
|
||
if (specificTarget != null) list.Add(specificTarget);
|
||
else { try { var tagged = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { var single = GameObject.Find("thisEnemy"); if (single != null) list.Add(single); } }
|
||
break;
|
||
case Selector.AllEntities:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
try { var taggedE = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in taggedE) if (!list.Contains(g)) list.Add(g); } catch { var single = GameObject.Find("thisEnemy"); if (single != null && !list.Contains(single)) list.Add(single); }
|
||
break;
|
||
}
|
||
list.RemoveAll(x => x == null);
|
||
var uniq = new List<GameObject>();
|
||
foreach (var g in list) if (!uniq.Contains(g)) uniq.Add(g);
|
||
return uniq;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void ModifyTotalScore(int delta)
|
||
{
|
||
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance is null"); return; }
|
||
// adjust total and attempt to update UI
|
||
ScoreManager.Instance.totalScore += delta;
|
||
// try to force UI update via ScoreManager.RecalculateTotal() is not appropriate because it recalculates from allies
|
||
// so instead update displayed total directly if teamUIController present
|
||
if (teamUIController.Instance != null && teamUIController.Instance.currentTotalScore != null)
|
||
{
|
||
teamUIController.Instance.currentTotalScore.text = ScoreManager.Instance.totalScore.ToString();
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ModifySingleAllyScoreDirect(GameObject allyObject, int delta)
|
||
{
|
||
if (allyObject == null) return;
|
||
var ally = allyObject.GetComponent<AllyCombatant>();
|
||
if (ally != null)
|
||
{
|
||
ally.AddScoreDirect(delta);
|
||
}
|
||
}
|
||
|
||
// Slot-based: modify single ally by slot index (no GameObject required)
|
||
public void ModifySingleAllyScoreDirectBySlot(int slotIndex, int delta)
|
||
{
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) return;
|
||
var ally = go.GetComponent<AllyCombatant>();
|
||
if (ally != null) ally.AddScoreDirect(delta);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ModifyGroupScoreDirect(Selector selector, GameObject caster, int delta)
|
||
{
|
||
var targets = ResolveTargetsLocal(selector, caster);
|
||
foreach (var t in targets)
|
||
{
|
||
var ally = t.GetComponent<AllyCombatant>();
|
||
if (ally != null) ally.AddScoreDirect(delta);
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ApplyScoreMultiplier(Selector selector, GameObject caster, float multiplier, float duration)
|
||
{
|
||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplier: invalid multiplier"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, multiplier, duration, caster, null);
|
||
}
|
||
|
||
// Slot-based helper: apply score multiplier to a single slot
|
||
public void ApplyScoreMultiplierToSlot(int slotIndex, float multiplier, float duration)
|
||
{
|
||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplierToSlot: invalid multiplier"); return; }
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) { Debug.LogWarning($"ApplyScoreMultiplierToSlot: no ally object for slot {slotIndex}"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.ScoreMultiplier, multiplier, duration, go, null);
|
||
}
|
||
|
||
// Slot-based helper: add flat score to a single slot (immediate)
|
||
public void AddScoreToSlot(int slotIndex, int delta)
|
||
{
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) { Debug.LogWarning($"AddScoreToSlot: no ally object for slot {slotIndex}"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.AddScore, delta, 0f, go, null);
|
||
}
|
||
|
||
private IEnumerator ApplyBuffToTargetsCoroutine(Buff buff, List<GameObject> targets, GameObject source)
|
||
{
|
||
if (buff == null) yield break;
|
||
foreach (var t in targets)
|
||
{
|
||
if (t == null) continue;
|
||
var comp = t.GetComponent<ICombatant>();
|
||
if (comp != null) comp.ApplyBuff(buff, source);
|
||
else Debug.LogWarning($"ApplyBuffToTargets: target {t.name} has no ICombatant");
|
||
}
|
||
if (buff.duration > 0f)
|
||
yield return new WaitForSeconds(buff.duration);
|
||
else
|
||
yield break;
|
||
|
||
foreach (var t in targets)
|
||
{
|
||
if (t == null) continue;
|
||
var comp = t.GetComponent<ICombatant>();
|
||
if (comp != null) comp.RemoveBuff(buff.buffId);
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void DealSingleEnemyDamage(GameObject caster, GameObject enemyTarget, float amount)
|
||
{
|
||
if (enemyTarget == null) { Debug.LogWarning("DealSingleEnemyDamage: enemyTarget == null"); return; }
|
||
ExecuteSkill("DealSingleEnemyDamage", EffectType.DamageSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||
}
|
||
|
||
// New: heal enemy directly (single)
|
||
public void HealSingleEnemy(GameObject caster, GameObject enemyTarget, float amount)
|
||
{
|
||
if (enemyTarget == null) { Debug.LogWarning("HealSingleEnemy: enemyTarget == null"); return; }
|
||
ExecuteSkill("HealSingleEnemy", EffectType.HealSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||
}
|
||
|
||
// New: heal enemy over time
|
||
public void ApplyHealOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("HealOverTimeEnemy", EffectType.HealOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
// New: damage allies directly (single)
|
||
public void DealSingleAllyDamage(GameObject caster, GameObject allyTarget, float amount)
|
||
{
|
||
if (allyTarget == null) { Debug.LogWarning("DealSingleAllyDamage: allyTarget == null"); return; }
|
||
// This is intended as a single-target ally damage. Use Selector.Self when a specificTarget is provided
|
||
// so target resolution treats the passed allyTarget as the explicit target.
|
||
ExecuteSkill("DealSingleAllyDamage", EffectType.DamageSingleAlly, amount, Selector.Self, caster, allyTarget);
|
||
}
|
||
|
||
// New: damage allies over time
|
||
public void ApplyDamageOverTimeToAllies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("DamageOverTimeAlly", EffectType.DamageOverTimeAlly, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void ApplyDamageOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("DamageOverTime", EffectType.DamageOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void HealSelf(GameObject caster, float amount)
|
||
{
|
||
ExecuteSkill("HealSelf", EffectType.HealSingleSelf, amount, Selector.Self, caster, caster);
|
||
}
|
||
|
||
public void HealSelfOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||
{
|
||
ExecuteSkill("HealSelfOverTime", EffectType.HealOverTimeSelf, totalAmount, Selector.Self, caster, caster, duration, tickInterval);
|
||
}
|
||
|
||
public void HealGroupSingle(GameObject caster, float amount, bool includeSelf = true)
|
||
{
|
||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||
ExecuteSkill("HealGroupSingle", EffectType.HealGroupSingle, amount, sel, caster);
|
||
}
|
||
|
||
public void HealGroupOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f, bool includeSelf = true)
|
||
{
|
||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||
ExecuteSkill("HealGroupOverTime", EffectType.HealGroupOverTime, totalAmount, sel, caster, null, duration, tickInterval);
|
||
}
|
||
|
||
public void IncreaseManaOverTime(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
ExecuteSkill("IncreaseManaOverTime", EffectType.IncreaseManaOverTime, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void ReduceEnemyHealOverTime(GameObject caster, Selector selector, float duration, GameObject specificTarget = null)
|
||
{
|
||
// Default to 50% heal reduction for the duration. Designers can use SkillDefinition.effectType directly
|
||
// with a custom formula/amount if they need other ratios.
|
||
ExecuteSkill("ReduceEnemyHealOverTime", EffectType.ReduceEnemyHealOverTime, 0.5f, selector, caster, specificTarget, duration);
|
||
}
|
||
|
||
public void HealAdjacentAllies(GameObject caster, float amount)
|
||
{
|
||
ExecuteSkill("HealAdjacentAllies", EffectType.HealGroupSingle, amount, Selector.AdjacentAllies, caster);
|
||
}
|
||
|
||
public void IncreaseManaAdjacentAllies(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||
{
|
||
ExecuteSkill("IncreaseManaAdjacentAllies", EffectType.IncreaseManaOverTime, totalAmount, Selector.AdjacentAllies, caster, null, duration, tickInterval);
|
||
}
|
||
|
||
public void Hero_A_Ultimate(GameObject caster, GameObject enemyTarget)
|
||
{
|
||
DealSingleEnemyDamage(caster, enemyTarget, defaultDamage * 2f);
|
||
HealAdjacentAllies(caster, defaultHeal * 0.5f);
|
||
}
|
||
|
||
public void MassHeal(GameObject caster)
|
||
{
|
||
HealGroupOverTime(caster, defaultHeal * 5f, 4f, 1f, true);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public GameObject GetAllyObjectBySlot(int slotIndex)
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null) return ui.GetAllyObjectBySlot(slotIndex);
|
||
var named = GameObject.Find($"ally_0{slotIndex + 1}");
|
||
return named;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public AllyHero_SO GetAllyHeroSOBySlot(int slotIndex)
|
||
{
|
||
// NOTE: slot index -> hero ID mapping can change between runs/scenes (PlayerPrefs load timing),
|
||
// so the cache must be validated against the current allySlotIds before returning.
|
||
var ui = teamUIController.Instance;
|
||
if (ui == null || ui.allySlotIds == null) return null;
|
||
if (slotIndex < 0 || slotIndex >= ui.allySlotIds.Count) return null;
|
||
int id = ui.allySlotIds[slotIndex];
|
||
if (id <= 0) return null;
|
||
|
||
// slot-based cache (depends on team selection)
|
||
if (_allyHeroSoBySlotCache != null && _allyHeroSoBySlotCache.TryGetValue(slotIndex, out var cached) && cached != null)
|
||
{
|
||
if (cached.ally_heroID == id) return cached;
|
||
// stale entry -> remove so we can rebuild below
|
||
_allyHeroSoBySlotCache.Remove(slotIndex);
|
||
}
|
||
|
||
// Ensure index is built
|
||
if (_allAllyHeroSOs == null || _allAllyHeroSOs.Length == 0) PrewarmAllyHeroSOIndex();
|
||
|
||
AllyHero_SO result = null;
|
||
if (_allyHeroSoById != null && _allyHeroSoById.TryGetValue(id, out var so) && so != null)
|
||
{
|
||
result = so;
|
||
}
|
||
else
|
||
{
|
||
// fallback to previous behavior if index missing
|
||
var all = Resources.LoadAll<AllyHero_SO>("");
|
||
foreach (var a in all)
|
||
{
|
||
if (a != null && a.ally_heroID == id) { result = a; break; }
|
||
}
|
||
}
|
||
|
||
if (result != null)
|
||
{
|
||
if (_allyHeroSoBySlotCache == null) _allyHeroSoBySlotCache = new Dictionary<int, AllyHero_SO>(8);
|
||
_allyHeroSoBySlotCache[slotIndex] = result;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
private AllyHero_SO.AllyLevelInfo GetEffectiveLevelInfo(AllyHero_SO so)
|
||
{
|
||
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
|
||
// choose the highest requiredEXP that is <= ally_currentEXP
|
||
AllyHero_SO.AllyLevelInfo best = null;
|
||
int currentExp = so.ally_currentEXP;
|
||
foreach (var lvl in so.levelStats)
|
||
{
|
||
if (best == null)
|
||
{
|
||
if (currentExp >= lvl.requiredEXP) best = lvl;
|
||
}
|
||
else
|
||
{
|
||
if (currentExp >= lvl.requiredEXP && lvl.requiredEXP >= best.requiredEXP) best = lvl;
|
||
}
|
||
}
|
||
// if no level matched (all requiredEXP > currentExp), fallback to lowest level (index 0)
|
||
if (best == null) return so.levelStats[0];
|
||
return best;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public int GetAllyBaseAttack(AllyHero_SO so)
|
||
{
|
||
if (so == null) return 0;
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
if (eff != null) return eff.attack;
|
||
if (so.levelStats != null && so.levelStats.Count > 0) return so.levelStats[0].attack;
|
||
return 0;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public float ComputeSkillValue(GameObject caster, int slotIndex, float inputValue, int skillStatic)
|
||
{
|
||
if (inputValue != -1f) return inputValue;
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
var ally = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
int allyAtk = ally != null ? ally.attack : GetAllyBaseAttack(so);
|
||
return skillStatic + allyAtk;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void AllySlotSkill(int slotIndex, string skillId, EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null, float tickInterval = 1f)
|
||
{
|
||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||
if (caster == null)
|
||
{
|
||
Debug.LogWarning($"AllySlotSkill: no caster found for slot {slotIndex}");
|
||
return;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
int skillStaticDamage = 0;
|
||
switch (skillId)
|
||
{
|
||
case "skill01": skillStaticDamage = 50; break;
|
||
case "skill_heal_small": skillStaticDamage = 30; break;
|
||
// Documentation text normalized.
|
||
default: skillStaticDamage = 0; break;
|
||
}
|
||
|
||
float finalValue = ComputeSkillValue(caster, slotIndex, value, skillStaticDamage);
|
||
|
||
// Documentation text normalized.
|
||
ExecuteSkill($"slot{slotIndex + 1}_{skillId}", effectType, finalValue, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
private static bool IsRefreshOnlyTimedSkill(SkillDefinition def)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
return s_refreshOnlyTimedSkillIds.Contains(def.skillId);
|
||
}
|
||
|
||
private static bool IsRefreshOnlyOverTimeSkill(SkillDefinition def)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
return s_refreshOnlyOverTimeSkillIds.Contains(def.skillId);
|
||
}
|
||
|
||
private static string BuildRefreshOnlyEffectKey(SkillDefinition def, GameObject target)
|
||
{
|
||
if (def == null || target == null || string.IsNullOrWhiteSpace(def.skillId)) return null;
|
||
return def.skillId + ":" + target.GetInstanceID();
|
||
}
|
||
|
||
private static bool ShouldSpawnApprForEffect(EffectType effectType)
|
||
{
|
||
switch (effectType)
|
||
{
|
||
case EffectType.DamageOverTimeAlly:
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
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.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
case EffectType.RedirectSelfDamageToAdjacent:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool TryApplyRefreshOnlyTimedEffect(SkillDefinition def, GameObject target, float amount, float duration)
|
||
{
|
||
if (!IsRefreshOnlyTimedSkill(def)) return false;
|
||
if (target == null || duration <= 0f) return false;
|
||
|
||
string key = BuildRefreshOnlyEffectKey(def, target);
|
||
if (string.IsNullOrEmpty(key)) return false;
|
||
|
||
if (_refreshOnlyTimedEffectStates.TryGetValue(key, out var existing))
|
||
{
|
||
RevertRefreshOnlyTimedEffect(target, existing);
|
||
if (_refreshOnlyTimedEffectCoroutines.TryGetValue(key, out var running) && running != null)
|
||
StopCoroutine(running);
|
||
}
|
||
|
||
if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied))
|
||
{
|
||
_refreshOnlyTimedEffectStates.Remove(key);
|
||
_refreshOnlyTimedEffectCoroutines.Remove(key);
|
||
return false;
|
||
}
|
||
|
||
var ally = target.GetComponent<AllyCombatant>() ?? target.GetComponentInChildren<AllyCombatant>(true);
|
||
var enemy = target.GetComponent<EnemyCombatant>() ?? target.GetComponentInChildren<EnemyCombatant>(true);
|
||
if (iBudeffPrefabController.Instance != null)
|
||
{
|
||
PlayerBudeffIconType? iconType = null;
|
||
switch (def.effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency: iconType = PlayerBudeffIconType.ot_scoreEfficiency_up; break;
|
||
case EffectType.DecreaseScoreEfficiency: iconType = PlayerBudeffIconType.ot_scoreEfficiency_down; break;
|
||
case EffectType.IncreaseDamageResistance: iconType = PlayerBudeffIconType.ot_defend_up; break;
|
||
case EffectType.DecreaseDamageResistance: iconType = PlayerBudeffIconType.ot_defend_down; break;
|
||
case EffectType.IncreaseAttack: iconType = PlayerBudeffIconType.ot_atk_up; break;
|
||
case EffectType.DecreaseAttack: iconType = PlayerBudeffIconType.ot_atk_down; break;
|
||
case EffectType.IncreaseMaxHP: iconType = PlayerBudeffIconType.ot_maxHP_up; break;
|
||
case EffectType.DecreaseMaxHP: iconType = PlayerBudeffIconType.ot_maxHP_down; break;
|
||
case EffectType.IncreaseMaxMana: iconType = PlayerBudeffIconType.ot_maxMana_up; break;
|
||
case EffectType.DecreaseMaxMana: iconType = PlayerBudeffIconType.ot_maxMana_down; break;
|
||
}
|
||
|
||
if (iconType.HasValue)
|
||
{
|
||
float v = applied.intDelta != 0 ? applied.intDelta : applied.floatDelta;
|
||
if (ally != null)
|
||
{
|
||
applied.budeffSlotIndex = ally.slotIndex;
|
||
applied.budeffIconId = iBudeffPrefabController.Instance.RegisterTimedEffect(ally, iconType.Value, v, duration);
|
||
}
|
||
else if (enemy != null && def.effectType != EffectType.IncreaseScoreEfficiency && def.effectType != EffectType.DecreaseScoreEfficiency)
|
||
{
|
||
applied.budeffEnemyInstanceId = enemy.GetInstanceID();
|
||
applied.budeffIconId = iBudeffPrefabController.Instance.RegisterEnemyTimedEffect(enemy, iconType.Value, v, duration);
|
||
}
|
||
}
|
||
}
|
||
|
||
_refreshOnlyTimedEffectStates[key] = applied;
|
||
_refreshOnlyTimedEffectCoroutines[key] = StartCoroutine(RemoveRefreshOnlyTimedEffectAfterDuration(key, target, duration));
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator RemoveRefreshOnlyTimedEffectAfterDuration(string key, GameObject target, float duration)
|
||
{
|
||
yield return new WaitForSeconds(duration);
|
||
|
||
if (_refreshOnlyTimedEffectStates.TryGetValue(key, out var state))
|
||
{
|
||
RevertRefreshOnlyTimedEffect(target, state);
|
||
_refreshOnlyTimedEffectStates.Remove(key);
|
||
}
|
||
|
||
_refreshOnlyTimedEffectCoroutines.Remove(key);
|
||
}
|
||
|
||
private bool TryApplyRefreshOnlyTimedEffectNow(GameObject target, EffectType effectType, float amount, out RefreshOnlyTimedState state)
|
||
{
|
||
state = null;
|
||
var ally = target != null ? target.GetComponent<AllyCombatant>() : null;
|
||
var enemy = target != null ? target.GetComponent<EnemyCombatant>() : null;
|
||
if (ally == null && enemy == null) return false;
|
||
|
||
string targetName = ally != null ? ally.allyName : (enemy != null && enemy.sourceData != null ? enemy.sourceData.enemyName : target.name);
|
||
|
||
switch (effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally == null) return false;
|
||
float scoreDelta = effectType == EffectType.IncreaseScoreEfficiency ? amount : -amount;
|
||
float oldScore = ally.scoreEfficiency;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency + scoreDelta);
|
||
CheckLimitAndWarn(targetName, "分数效率", oldScore, ally.scoreEfficiency, scoreDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, floatDelta = scoreDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
float resistDelta = effectType == EffectType.IncreaseDamageResistance ? amount : -amount;
|
||
float oldResist = ally != null ? ally.damageResistance : enemy.damageResistance;
|
||
if (ally != null) ally.damageResistance = ClampDamageResistance(ally.damageResistance + resistDelta);
|
||
else enemy.damageResistance = ClampDamageResistance(enemy.damageResistance + resistDelta);
|
||
float newResist = ally != null ? ally.damageResistance : enemy.damageResistance;
|
||
CheckLimitAndWarn(targetName, "伤害抗性", oldResist, newResist, resistDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, floatDelta = resistDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
int atkDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseAttack) atkDelta = -atkDelta;
|
||
int oldAtk = ally != null ? ally.attack : enemy.attack;
|
||
if (ally != null) ally.ModifyAttack(atkDelta);
|
||
else enemy.ModifyAttack(atkDelta);
|
||
int newAtk = ally != null ? ally.attack : enemy.attack;
|
||
CheckLimitAndWarnInt(targetName, "攻击力", oldAtk, newAtk, atkDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = atkDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
int hpDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseMaxHP) hpDelta = -hpDelta;
|
||
int oldHP = ally != null ? ally.maxHP : enemy.maxHP;
|
||
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP + hpDelta), false);
|
||
else enemy.SetMaxHP(Mathf.Max(1, enemy.maxHP + hpDelta), false);
|
||
int newHP = ally != null ? ally.maxHP : enemy.maxHP;
|
||
CheckLimitAndWarnInt(targetName, "最大生命值", oldHP, newHP, hpDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = hpDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
int manaDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseMaxMana) manaDelta = -manaDelta;
|
||
int oldMana = ally != null ? ally.maxMana : enemy.maxMana;
|
||
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana + manaDelta), false);
|
||
else enemy.SetMaxMana(Mathf.Max(1, enemy.maxMana + manaDelta), false);
|
||
int newMana = ally != null ? ally.maxMana : enemy.maxMana;
|
||
CheckLimitAndWarnInt(targetName, "最大法力值", oldMana, newMana, manaDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = manaDelta };
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void CheckLimitAndWarn(string targetName, string attrName, float startValue, float newValue, float attemptedDelta)
|
||
{
|
||
float actualChange = newValue - startValue;
|
||
// If we attempted a change but the actual change differs significantly from attempted, we hit a limit.
|
||
if (Mathf.Abs(attemptedDelta) > 0.001f && Mathf.Abs(actualChange - attemptedDelta) > 0.001f)
|
||
{
|
||
load_skillwarn.Instance?.ShowWarning(targetName, attrName);
|
||
}
|
||
}
|
||
|
||
private void CheckLimitAndWarnInt(string targetName, string attrName, int startValue, int newValue, int attemptedDelta)
|
||
{
|
||
int actualChange = newValue - startValue;
|
||
if (attemptedDelta != 0 && actualChange != attemptedDelta)
|
||
{
|
||
load_skillwarn.Instance?.ShowWarning(targetName, attrName);
|
||
}
|
||
}
|
||
|
||
private static float ClampDamageResistance(float value)
|
||
{
|
||
return Mathf.Min(value, 1f);
|
||
}
|
||
|
||
private void RevertRefreshOnlyTimedEffect(GameObject target, RefreshOnlyTimedState state)
|
||
{
|
||
if (target == null || state == null) return;
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
var enemy = target.GetComponent<EnemyCombatant>();
|
||
if (ally == null && enemy == null) return;
|
||
|
||
switch (state.effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally != null)
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - state.floatDelta);
|
||
break;
|
||
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
if (ally != null) ally.damageResistance = ClampDamageResistance(ally.damageResistance - state.floatDelta);
|
||
else enemy.damageResistance = ClampDamageResistance(enemy.damageResistance - state.floatDelta);
|
||
break;
|
||
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
if (ally != null) ally.ModifyAttack(-state.intDelta);
|
||
else enemy.ModifyAttack(-state.intDelta);
|
||
break;
|
||
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP - state.intDelta), false);
|
||
else enemy.SetMaxHP(Mathf.Max(1, enemy.maxHP - state.intDelta), false);
|
||
break;
|
||
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana - state.intDelta), false);
|
||
else enemy.SetMaxMana(Mathf.Max(1, enemy.maxMana - state.intDelta), false);
|
||
break;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(state.budeffIconId))
|
||
{
|
||
if (state.budeffSlotIndex >= 0) iBudeffPrefabController.Instance?.UnregisterTimedEffect(state.budeffSlotIndex, state.budeffIconId);
|
||
else if (state.budeffEnemyInstanceId != 0) iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(state.budeffEnemyInstanceId, state.budeffIconId);
|
||
state.budeffIconId = null;
|
||
state.budeffSlotIndex = -1;
|
||
state.budeffEnemyInstanceId = 0;
|
||
}
|
||
}
|
||
|
||
private bool TryApplyRefreshOnlyOverTimeEffect(SkillDefinition def, GameObject target, float totalAmount, GameObject source)
|
||
{
|
||
if (!IsRefreshOnlyOverTimeSkill(def)) return false;
|
||
if (target == null || def.defaultDuration <= 0f) return false;
|
||
if (def.effectType != EffectType.HealGroupOverTime && def.effectType != EffectType.IncreaseManaOverTime) return false;
|
||
|
||
string key = BuildRefreshOnlyEffectKey(def, target);
|
||
if (string.IsNullOrEmpty(key)) return false;
|
||
|
||
if (_refreshOnlyOverTimeCoroutines.TryGetValue(key, out var running) && running != null)
|
||
StopCoroutine(running);
|
||
|
||
_refreshOnlyOverTimeCoroutines[key] = StartCoroutine(ApplyRefreshOnlyOverTimeEffectCoroutine(key, def, target, totalAmount, source));
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator ApplyRefreshOnlyOverTimeEffectCoroutine(string key, SkillDefinition def, GameObject target, float totalAmount, GameObject source)
|
||
{
|
||
float duration = Mathf.Max(0f, def.defaultDuration);
|
||
float tickInterval = def.GetEffectiveTickInterval();
|
||
if (tickInterval <= 0f) tickInterval = 1f;
|
||
|
||
if (duration <= 0f)
|
||
{
|
||
ApplyRefreshOnlyOverTimeTick(def.effectType, target, totalAmount, source);
|
||
_refreshOnlyOverTimeCoroutines.Remove(key);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (target == null) break;
|
||
if (!ApplyRefreshOnlyOverTimeTick(def.effectType, target, perTick, source)) break;
|
||
|
||
yield return new WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
|
||
_refreshOnlyOverTimeCoroutines.Remove(key);
|
||
}
|
||
|
||
private bool ApplyRefreshOnlyOverTimeTick(EffectType effectType, GameObject target, float amount, GameObject source)
|
||
{
|
||
if (target == null) return false;
|
||
|
||
switch (effectType)
|
||
{
|
||
case EffectType.HealGroupOverTime:
|
||
case EffectType.HealOverTimeSelf:
|
||
case EffectType.HealOverTimeEnemy:
|
||
var comp = target.GetComponent<ICombatant>();
|
||
if (comp == null) return false;
|
||
comp.ReceiveHeal(amount, source);
|
||
if (source != null)
|
||
{
|
||
var sourceAlly = source.GetComponent<AllyCombatant>();
|
||
if (sourceAlly != null && teamUIController.Instance != null)
|
||
teamUIController.Instance.RecordHeal(sourceAlly.slotIndex, amount);
|
||
}
|
||
return true;
|
||
|
||
case EffectType.IncreaseManaOverTime:
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null) return false;
|
||
ally.ModifyMana(Mathf.CeilToInt(amount), true, true);
|
||
if (amount > 0f && source != null)
|
||
{
|
||
var sourceAlly = source.GetComponent<AllyCombatant>();
|
||
if (sourceAlly != null && teamUIController.Instance != null)
|
||
teamUIController.Instance.RecordMana(sourceAlly.slotIndex, amount);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// New: invoke a SkillDefinition directly (respects staticValue and SO attack when value == -1)
|
||
public void UseSkillDefinition(SkillDefinition def, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
if (def == null) { Debug.LogWarning("UseSkillDefinition: null def"); return; }
|
||
|
||
int groupId = 0;
|
||
string smallSkillName = null;
|
||
Sprite smallSkillIcon = null;
|
||
// Resolve group/icon metadata for the ally HUD skill icon queue.
|
||
try
|
||
{
|
||
var heroSoForName = GetAllyHeroSOBySlot(slotIndex);
|
||
|
||
// Documentation text normalized.
|
||
if (heroSoForName != null && heroSoForName.skillGroups != null)
|
||
{
|
||
foreach (var g in heroSoForName.skillGroups)
|
||
{
|
||
if (g == null || g.skills == null) continue;
|
||
foreach (var sd in g.skills)
|
||
{
|
||
if (sd == null) continue;
|
||
if (sd == def)
|
||
{
|
||
groupId = g.skillGroupID;
|
||
if (!string.IsNullOrWhiteSpace(g.groupName)) smallSkillName = g.groupName;
|
||
smallSkillIcon = g.groupIcon;
|
||
goto ResolvedGroup;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: match by skillId when the SkillDefinition instance isn't the same reference.
|
||
if (!string.IsNullOrWhiteSpace(def.skillId))
|
||
{
|
||
foreach (var g in heroSoForName.skillGroups)
|
||
{
|
||
if (g == null || g.skills == null) continue;
|
||
foreach (var sd in g.skills)
|
||
{
|
||
if (sd == null) continue;
|
||
if (!string.IsNullOrWhiteSpace(sd.skillId) && sd.skillId == def.skillId)
|
||
{
|
||
groupId = g.skillGroupID;
|
||
if (!string.IsNullOrWhiteSpace(g.groupName)) smallSkillName = g.groupName;
|
||
smallSkillIcon = g.groupIcon;
|
||
goto ResolvedGroup;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ResolvedGroup:
|
||
|
||
if (string.IsNullOrWhiteSpace(smallSkillName))
|
||
{
|
||
// Fallback: best-effort readable name.
|
||
if (!string.IsNullOrWhiteSpace(def.summaryInfo)) smallSkillName = def.summaryInfo;
|
||
else if (!string.IsNullOrWhiteSpace(def.displayName)) smallSkillName = def.displayName;
|
||
else smallSkillName = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name;
|
||
}
|
||
|
||
// Dedup: avoid printing multiple times for the same group in a single frame.
|
||
int frame = Time.frameCount;
|
||
int keySlot = slotIndex < 0 ? 0 : slotIndex;
|
||
long key;
|
||
if (groupId != 0)
|
||
key = ((long)keySlot << 32) | (uint)groupId;
|
||
else
|
||
key = ((long)keySlot << 32) | (uint)(smallSkillName.GetHashCode());
|
||
|
||
int lastFrame;
|
||
if (!s_lastSkillFeedFrameByKey.TryGetValue(key, out lastFrame) || lastFrame != frame)
|
||
{
|
||
s_lastSkillFeedFrameByKey[key] = frame;
|
||
|
||
// Push to text feed
|
||
if (heroSoForName != null && !string.IsNullOrEmpty(heroSoForName.ally_heroName))
|
||
{
|
||
SkillTriggerFeedUI.Push(heroSoForName.ally_heroName, smallSkillName);
|
||
}
|
||
|
||
// Ally HUD skill icon feed (newest first, max 3).
|
||
if (smallSkillIcon != null && slotIndex >= 0 && slotIndex < 5)
|
||
{
|
||
try
|
||
{
|
||
var allyGo = GetAllyObjectBySlot(slotIndex);
|
||
var ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null) ally.PushSkillIcon(smallSkillIcon);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
}
|
||
catch { /* never break gameplay because of optional UI */ }
|
||
|
||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||
if (caster == null)
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: no caster GameObject found for slot {slotIndex}. Proceeding with null caster.");
|
||
}
|
||
|
||
// Extra attempts to resolve caster when null: try teamUIController and name pattern fallback
|
||
if (caster == null)
|
||
{
|
||
try
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
var alt = ui.GetAllyObjectBySlot(slotIndex);
|
||
if (alt != null)
|
||
{
|
||
caster = alt;
|
||
LogVerbose($"UseSkillDefinition: resolved caster via teamUIController for slot {slotIndex} -> {caster.name}");
|
||
}
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
if (caster == null)
|
||
{
|
||
var byName = GameObject.Find($"ally_0{slotIndex + 1}");
|
||
if (byName != null)
|
||
{
|
||
caster = byName;
|
||
LogVerbose($"UseSkillDefinition: resolved caster via name ally_0{slotIndex + 1} -> {caster.name}");
|
||
}
|
||
}
|
||
|
||
// Log useful debug info for diagnosing OnEnemyDead->Self issues
|
||
LogVerbose($"[SkillBuilder] UseSkillDefinition: casting skill {def.skillId} for slot {slotIndex} (caster={(caster?caster.name:"null")}) selector={def.defaultSelector} operateDirectly={def.operateDirectly} inputValue={inputValue} specificTarget={(specificTarget?specificTarget.name:"null")}");
|
||
|
||
float amount = 0f;
|
||
// If caller provided explicit inputValue use it
|
||
if (inputValue != -1f)
|
||
{
|
||
amount = inputValue;
|
||
}
|
||
else
|
||
{
|
||
// Build variables from SO and caster; formula can be a plain number too
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
var casterAlly = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
var vars = _varsBuffer;
|
||
vars.Clear();
|
||
vars["slot"] = slotIndex;
|
||
|
||
// Prefer runtime values so buffs/debuffs and max stat changes affect formulas.
|
||
if (casterAlly != null)
|
||
{
|
||
vars["attack"] = casterAlly.attack;
|
||
vars["maxHP"] = casterAlly.maxHP;
|
||
vars["maxMana"] = casterAlly.maxMana;
|
||
vars["damageResistance"] = casterAlly.damageResistance;
|
||
vars["scoreEfficiency"] = casterAlly.scoreEfficiency;
|
||
}
|
||
else if (so != null && so.levelStats != null && so.levelStats.Count > 0)
|
||
{
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
var lvl = eff ?? so.levelStats[0];
|
||
vars["attack"] = lvl.attack;
|
||
vars["maxHP"] = lvl.maxHP;
|
||
vars["maxMana"] = lvl.maxMana;
|
||
vars["damageResistance"] = lvl.damageResistance;
|
||
vars["scoreEfficiency"] = lvl.scoreEfficiency;
|
||
}
|
||
else
|
||
{
|
||
vars["attack"] = GetAllyBaseAttack(so);
|
||
vars["maxHP"] = 0f;
|
||
vars["maxMana"] = 0f;
|
||
vars["damageResistance"] = 0f;
|
||
vars["scoreEfficiency"] = 1f;
|
||
}
|
||
|
||
// Expose level variables (base level from SO).
|
||
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
|
||
{
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
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;
|
||
}
|
||
if (so != null) vars["ally_currentEXP"] = so.ally_currentEXP;
|
||
else vars["ally_currentEXP"] = 0f;
|
||
vars["currentMana"] = casterAlly != null ? casterAlly.currentMana : 0f;
|
||
vars["currentScore"] = casterAlly != null ? casterAlly.currentScore : 0f;
|
||
vars["idolScore"] = vars["currentScore"];
|
||
vars["currentHP"] = casterAlly != null ? casterAlly.currentHP : 0f;
|
||
|
||
if (string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: skill {def.skillId} has empty formula, defaulting to 0");
|
||
amount = 0f;
|
||
}
|
||
else
|
||
{
|
||
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: formula evaluation failed for skill {def.skillId}, defaulting to 0");
|
||
amount = 0f;
|
||
}
|
||
else
|
||
{
|
||
amount = fresult;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert formula result (interpreted as per-tick when skill is sustained) to total amount
|
||
// amount now represents the formula result (per-tick for sustained effects, instant for single-instance)
|
||
float amountPerTick = amount;
|
||
float amountTotal = amountPerTick;
|
||
|
||
// Only some effect types treat formula as "per tick". Others (buffs/debuffs/stat changes) should use the
|
||
// raw formula value even if designers leave a non-zero tickInterval by accident.
|
||
bool scaleByTicks =
|
||
!def.IsSingleInstance &&
|
||
(def.effectType == EffectType.DamageOverTimeEnemy ||
|
||
def.effectType == EffectType.DamageOverTimeAlly ||
|
||
def.effectType == EffectType.HealOverTimeEnemy ||
|
||
def.effectType == EffectType.HealOverTimeSelf ||
|
||
def.effectType == EffectType.HealGroupOverTime ||
|
||
def.effectType == EffectType.IncreaseManaOverTime);
|
||
|
||
if (scaleByTicks)
|
||
{
|
||
// compute ticks based on def duration and effective tick interval
|
||
float tickInterval = def.GetEffectiveTickInterval();
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / tickInterval));
|
||
amountTotal = amountPerTick * ticks;
|
||
}
|
||
|
||
// If this skill is intended to be triggered by note hits, treat it as a one-time application
|
||
// (do not multiply by duration ticks). This prevents OnNoteHit triggers from unexpectedly
|
||
// applying sustained totals (e.g. 5s * perTick) when the designer expects a single effect.
|
||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnNoteHit)
|
||
{
|
||
amountTotal = amountPerTick;
|
||
}
|
||
|
||
// Repeat-window override (stateful): if this skill is triggered again within the window,
|
||
// override the computed amount with repeatValue. Used by effects like "300 score, but 500 if retriggered within 5s".
|
||
if (def.repeatWindowSeconds > 0f && def.repeatValue != 0f)
|
||
{
|
||
string sid = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name;
|
||
string repeatKey = $"{slotIndex}:{sid}";
|
||
float now = Time.time;
|
||
if (_lastSkillTriggerTime.TryGetValue(repeatKey, out float last) && (now - last) <= def.repeatWindowSeconds)
|
||
{
|
||
amountTotal = def.repeatValue;
|
||
}
|
||
_lastSkillTriggerTime[repeatKey] = now;
|
||
}
|
||
|
||
if (def.operateDirectly)
|
||
{
|
||
// Resolve targets as GameObjects but operate on their AllyCombatant / ICombatant data directly
|
||
List<GameObject> targets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||
if (targets == null || targets.Count == 0)
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: no targets for direct operation skill {def.skillId}");
|
||
return;
|
||
}
|
||
|
||
foreach (var t in targets)
|
||
{
|
||
if (t == null) continue;
|
||
|
||
if (GameConfig.skillDebugMode)
|
||
{
|
||
Debug.Log($"<color=#00FFFF>[SkillDebug]</color> <color=#FFD700>ID: {def.skillId}</color> | <color=#00FF00>Trigger: {def.triggerCondition}</color> | <color=#FFA500>Target: {t.name}</color> | <color=#EE82EE>Effect: {def.effectType}</color> | <color=#FF4500>Value: {amountTotal:F2}</color>");
|
||
}
|
||
|
||
var ally = t.GetComponent<AllyCombatant>();
|
||
var ic = t.GetComponent<ICombatant>();
|
||
|
||
if (ally != null && ShouldSpawnApprForEffect(def.effectType))
|
||
{
|
||
var apprSprite = iBudeffPrefabController.Instance != null ? iBudeffPrefabController.Instance.GetSpriteForEffect(def.effectType) : null;
|
||
if (apprSprite != null) iBudeffPrefabController.Instance?.SpawnApprForSlot(ally.slotIndex, apprSprite);
|
||
}
|
||
|
||
switch (def.effectType)
|
||
{
|
||
case EffectType.DamageSingleEnemy:
|
||
// treat as damage to an enemy; if target has AllyCombatant, apply negative HP
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountTotal), true);
|
||
else if (ic != null) ic.ReceiveDamage(amountTotal, caster);
|
||
break;
|
||
case EffectType.DamageOverTimeEnemy:
|
||
// sustained: start coroutine to apply per-tick damage rather than instant total
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountPerTick), true);
|
||
else if (ic != null) ic.ReceiveDamage(amountPerTick, caster);
|
||
}
|
||
else
|
||
{
|
||
string iconId = null;
|
||
int allySlotIndex = -1;
|
||
int enemyInstanceId = 0;
|
||
var enemyDot = t.GetComponent<EnemyCombatant>() ?? t.GetComponentInChildren<EnemyCombatant>(true);
|
||
if (enemyDot != null)
|
||
{
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / def.GetEffectiveTickInterval()));
|
||
float perTick = amountTotal / ticks;
|
||
iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemyDot, PlayerBudeffIconType.ot_bleeding, perTick, def.defaultDuration);
|
||
enemyInstanceId = enemyDot.GetInstanceID();
|
||
}
|
||
else if (ally != null)
|
||
{
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / def.GetEffectiveTickInterval()));
|
||
float perTick = amountTotal / ticks;
|
||
iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_bleeding, perTick, def.defaultDuration);
|
||
allySlotIndex = ally.slotIndex;
|
||
}
|
||
StartCoroutine(ApplyDamageOverTimeDirect(t, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster, iconId, allySlotIndex, enemyInstanceId));
|
||
}
|
||
break;
|
||
case EffectType.HealSingleSelf:
|
||
case EffectType.HealGroupSingle:
|
||
if (ally != null) ally.ReceiveHeal(amountTotal, caster);
|
||
else if (ic != null) ic.ReceiveHeal(amountTotal, caster);
|
||
break;
|
||
case EffectType.HealOverTimeSelf:
|
||
case EffectType.HealGroupOverTime:
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(amountPerTick, caster);
|
||
else ic?.ReceiveHeal(amountPerTick, caster);
|
||
}
|
||
else
|
||
{
|
||
StartCoroutine(ApplyHealOverTimeDirect(t, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster));
|
||
}
|
||
break;
|
||
case EffectType.IncreaseManaOverTime:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
// single instance - amountPerTick equals immediate amount
|
||
ally.ModifyMana(Mathf.CeilToInt(amountPerTick), true, true);
|
||
}
|
||
else
|
||
{
|
||
// for sustained, pass total amount (amountTotal) to coroutine which expects total
|
||
StartCoroutine(ApplyIncreaseManaOverTimeDirect(ally, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval()));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
if (ic != null)
|
||
{
|
||
float reduction = Mathf.Clamp01(amountPerTick);
|
||
var deb = new Buff
|
||
{
|
||
buffId = System.Guid.NewGuid().ToString(),
|
||
duration = def.defaultDuration,
|
||
description = "ReduceHeal",
|
||
healReceivedMultiplier = 1f - reduction
|
||
};
|
||
ic.ApplyBuff(deb, caster);
|
||
StartCoroutine(RemoveBuffAfterDuration(t, deb.buffId, deb.duration));
|
||
}
|
||
break;
|
||
case EffectType.BuffDuration:
|
||
case EffectType.DebuffDuration:
|
||
if (ic != null)
|
||
{
|
||
// Generic timed buff/debuff: use amount as score multiplier (1.5 = +50%, 0.5 = -50%).
|
||
float mult = amountPerTick;
|
||
if (mult <= 0f) mult = 1f;
|
||
var b = new Buff
|
||
{
|
||
buffId = System.Guid.NewGuid().ToString(),
|
||
duration = def.defaultDuration,
|
||
scoreMultiplier = mult
|
||
};
|
||
ic.ApplyBuff(b, caster);
|
||
StartCoroutine(RemoveBuffAfterDuration(t, b.buffId, b.duration));
|
||
}
|
||
break;
|
||
// Documentation text normalized.
|
||
case EffectType.IncreaseMaxHP:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxHP(ally.maxHP + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxHP_up, d, 0f);
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy.SetMaxHP(enemy.maxHP + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy, PlayerBudeffIconType.ot_maxHP_up, d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.DecreaseMaxHP:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxHP(Mathf.Max(1, ally.maxHP - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxHP_down, -d, 0f);
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy2))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy2.SetMaxHP(Mathf.Max(1, enemy2.maxHP - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy2, PlayerBudeffIconType.ot_maxHP_down, -d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.IncreaseMaxMana:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxMana(ally.maxMana + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxMana_up, d, 0f);
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy3))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy3.SetMaxMana(enemy3.maxMana + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy3, PlayerBudeffIconType.ot_maxMana_up, d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.DecreaseMaxMana:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxMana(Mathf.Max(1, ally.maxMana - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxMana_down, -d, 0f);
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy4))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy4.SetMaxMana(Mathf.Max(1, enemy4.maxMana - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy4, PlayerBudeffIconType.ot_maxMana_down, -d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.scoreEfficiency += amountTotal;
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_up, amountTotal, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_up, amountTotal, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, amountTotal, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_down, -amountTotal, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_down, -amountTotal, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, -amountTotal, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.IncreaseAttack:
|
||
{
|
||
int delta = Mathf.CeilToInt(amountTotal);
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_up, delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_up, delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, delta, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy5))
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
enemy5.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy5, PlayerBudeffIconType.ot_atk_up, delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy5, PlayerBudeffIconType.ot_atk_up, delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy5, delta, def.defaultDuration, iconId, enemy5.GetInstanceID()));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.DecreaseAttack:
|
||
{
|
||
int delta = Mathf.CeilToInt(amountTotal);
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.ModifyAttack(-delta);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_down, -delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_down, -delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, -delta, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy6))
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
enemy6.ModifyAttack(-delta);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy6, PlayerBudeffIconType.ot_atk_down, -delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy6, PlayerBudeffIconType.ot_atk_down, -delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy6, -delta, def.defaultDuration, iconId, enemy6.GetInstanceID()));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
if (ally != null) AllyCombatant.ActivateNextDamageRedirect(ally, def.defaultDuration);
|
||
break;
|
||
default:
|
||
// non-direct path handled below, but keep compatibility
|
||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, amountTotal, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (GameConfig.skillDebugMode)
|
||
{
|
||
Debug.Log($"<color=#00FFFF>[SkillDebug]</color> <color=#FFD700>ID: {def.skillId}</color> | <color=#00FF00>Trigger: {def.triggerCondition}</color> | <color=#FFA500>Target: (Resolving via EffectSystem)</color> | <color=#EE82EE>Effect: {def.effectType}</color> | <color=#FF4500>Value: {amountTotal:F2}</color>");
|
||
}
|
||
|
||
if (ShouldSpawnApprForEffect(def.effectType))
|
||
{
|
||
var apprSprite = iBudeffPrefabController.Instance != null ? iBudeffPrefabController.Instance.GetSpriteForEffect(def.effectType) : null;
|
||
if (apprSprite != null)
|
||
{
|
||
var apprTargets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||
if (apprTargets != null)
|
||
{
|
||
foreach (var t in apprTargets)
|
||
{
|
||
if (t == null) continue;
|
||
var ally = t.GetComponent<AllyCombatant>() ?? t.GetComponentInChildren<AllyCombatant>(true);
|
||
if (ally != null) iBudeffPrefabController.Instance?.SpawnApprForSlot(ally.slotIndex, apprSprite);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (def.defaultDuration > 0f)
|
||
{
|
||
var resolvedTargets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||
bool appliedRefreshOnly = false;
|
||
if (resolvedTargets != null)
|
||
{
|
||
foreach (var t in resolvedTargets)
|
||
{
|
||
if (t == null) continue;
|
||
bool handled = TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration);
|
||
if (!handled) handled = TryApplyRefreshOnlyOverTimeEffect(def, t, amountTotal, caster);
|
||
if (handled) appliedRefreshOnly = true;
|
||
}
|
||
}
|
||
|
||
// For listed skills: do not stack when retriggered, only refresh duration.
|
||
if (appliedRefreshOnly) return;
|
||
}
|
||
|
||
// For non-direct path, EffectSystem expects 'amount' to be total amount for sustained effects
|
||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, amountTotal, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||
}
|
||
}
|
||
|
||
// Use the selected skill index from an AllyHero_SO for a given slot (calls UseSkillDefinition)
|
||
public void UseSelectedSkillForSlot(int slotIndex, int selectedSkillIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null || so.availableSkills == null || selectedSkillIndex < 0 || selectedSkillIndex >= so.availableSkills.Length)
|
||
{
|
||
Debug.LogWarning($"UseSelectedSkillForSlot: invalid selection for slot {slotIndex}");
|
||
return;
|
||
}
|
||
UseSkillDefinition(so.availableSkills[selectedSkillIndex], slotIndex, inputValue, specificTarget);
|
||
}
|
||
|
||
// Use the primary skill configured in the AllyHero_SO for this slot (primarySkillIndex dropdown)
|
||
// Updated: runtime activation now prefers equippedSkillGroupIDs on the SO. Only skills inside equipped groups are considered active.
|
||
public void UsePrimarySkillForSlot(int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
if (so == null)
|
||
{
|
||
Debug.LogWarning($"UsePrimarySkillForSlot: no SO for slot {slotIndex}");
|
||
return;
|
||
}
|
||
|
||
// If SO defines equipped group IDs, cast skills from those groups (these represent active/owned skills at runtime)
|
||
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;
|
||
UseSkillGroupForSlot(group, slotIndex, inputValue, specificTarget);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Fallback: prefer any defined primary group (SO-level) via GetPrimarySkillGroup(), otherwise use primarySkillIndex
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null)
|
||
{
|
||
UseSkillGroupForSlot(fallbackGroup, slotIndex, inputValue, specificTarget);
|
||
return;
|
||
}
|
||
|
||
int idx = so.primarySkillIndex;
|
||
if (idx < 0) { Debug.LogWarning($"UsePrimarySkillForSlot: primarySkillIndex not set for slot {slotIndex}"); return; }
|
||
UseSelectedSkillForSlot(slotIndex, idx, inputValue, specificTarget);
|
||
}
|
||
|
||
// Cast all non-null skills in a SkillGroup for a given slotIndex. Each skill is invoked via UseSkillDefinition.
|
||
public void UseSkillGroupForSlot(SkillGroup group, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
if (group == null) return;
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var s = group.skills[i];
|
||
if (s == null) continue;
|
||
UseSkillDefinition(s, slotIndex, inputValue, specificTarget);
|
||
LogVerbose($"[SkillBuilder] UseSkillGroupForSlot: cast skill {s.skillId} from group '{group.groupName}' for slot {slotIndex}");
|
||
}
|
||
}
|
||
|
||
// Called at game start to trigger any allies whose primary skill is set to trigger on game start
|
||
public void TriggerOnGameStart()
|
||
{
|
||
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return;
|
||
int slots = teamUIController.Instance.allySlotIds.Count;
|
||
for (int i = 0; i < slots; i++)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(i);
|
||
if (so == null) continue;
|
||
|
||
// If equipped groups exist, iterate them and cast group skills whose triggerCondition == OnGameStart
|
||
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 sk in group.skills)
|
||
{
|
||
if (sk == null) continue;
|
||
if (sk.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnGameStart: casting equipped group skill {sk.skillId} for slot {i}");
|
||
UseSkillDefinition(sk, i, -1f, null);
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Fallback: check SO-level primary group via GetPrimarySkillGroup()
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null)
|
||
{
|
||
foreach (var sk in fallbackGroup.skills)
|
||
{
|
||
if (sk == null) continue;
|
||
if (sk.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnGameStart: casting group skill {sk.skillId} for slot {i}");
|
||
UseSkillDefinition(sk, i, -1f, null);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var def = so.GetPrimarySkill();
|
||
if (def == null) continue;
|
||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnGameStart: casting primary skill {def.skillId} for slot {i}");
|
||
UsePrimarySkillForSlot(i, -1f, null);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger ally skills when a single enemy is defeated
|
||
public void TriggerOnEnemyDead(EnemyCombatant deadEnemy)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
// Determine number of ally slots; prefer teamUIController but fallback to 5
|
||
int slots = 5;
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||
slots = teamUIController.Instance.allySlotIds.Count;
|
||
else
|
||
Debug.LogWarning("[SkillBuilder] TriggerOnEnemyDead: teamUIController.Instance or allySlotIds is null - falling back to 5 slots");
|
||
|
||
for (int i = 0; i < slots; i++)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(i);
|
||
if (so == null) continue;
|
||
|
||
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 def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyDead) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyDead: slot {i+1} casting skill {def.skillId}");
|
||
// Only pass deadEnemy as specificTarget when the skill truly expects a specific target (e.g. targets CurrentEnemies)
|
||
GameObject ctxTarget = null;
|
||
if (deadEnemy != null && (def.requiresSpecificTarget || def.defaultSelector == Selector.CurrentEnemies)) ctxTarget = deadEnemy.gameObject;
|
||
UseSkillDefinition(def, i, -1f, ctxTarget);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup != null)
|
||
{
|
||
foreach (var def in fallbackGroup.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyDead) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyDead: slot {i+1} casting skill {def.skillId}");
|
||
GameObject ctxTarget = null;
|
||
if (deadEnemy != null && (def.requiresSpecificTarget || def.defaultSelector == Selector.CurrentEnemies)) ctxTarget = deadEnemy.gameObject;
|
||
UseSkillDefinition(def, i, -1f, ctxTarget);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var primary = so.GetPrimarySkill();
|
||
if (primary != null && primary.triggerCondition == SkillDefinition.SkillTrigger.OnEnemyDead)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyDead: slot {i+1} casting primary skill {primary.skillId}");
|
||
// Only pass deadEnemy as specificTarget when the skill truly expects a specific target (e.g. targets CurrentEnemies)
|
||
GameObject ctxTarget = null;
|
||
if (deadEnemy != null && (primary.requiresSpecificTarget || primary.defaultSelector == Selector.CurrentEnemies)) ctxTarget = deadEnemy.gameObject;
|
||
UsePrimarySkillForSlot(i, -1f, ctxTarget);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger ally skills when an enemy revives/enters
|
||
public void TriggerOnEnemyRevive(EnemyCombatant enemy)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
int slots = 5;
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||
slots = teamUIController.Instance.allySlotIds.Count;
|
||
else
|
||
Debug.LogWarning("[SkillBuilder] TriggerOnEnemyRevive: teamUIController.Instance or allySlotIds is null - falling back to 5 slots");
|
||
|
||
for (int i = 0; i < slots; i++)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(i);
|
||
if (so == null) continue;
|
||
|
||
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 def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyRevive) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyRevive: slot {i+1} casting skill {def.skillId}");
|
||
UseSkillDefinition(def, i, -1f, enemy != null ? enemy.gameObject : null);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var fallbackGroup2 = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup2 != null)
|
||
{
|
||
foreach (var def in fallbackGroup2.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyRevive) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyRevive: slot {i+1} casting skill {def.skillId} (from primary group)");
|
||
UseSkillDefinition(def, i, -1f, enemy != null ? enemy.gameObject : null);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var primary2 = so.GetPrimarySkill();
|
||
if (primary2 != null && primary2.triggerCondition == SkillDefinition.SkillTrigger.OnEnemyRevive)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyRevive: slot {i+1} casting primary skill {primary2.skillId}");
|
||
UsePrimarySkillForSlot(i, -1f, enemy != null ? enemy.gameObject : null);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger ally skills when all enemies are defeated
|
||
public void TriggerOnAllEnemiesDefeated()
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return;
|
||
int slots = teamUIController.Instance.allySlotIds.Count;
|
||
for (int i = 0; i < slots; i++)
|
||
{
|
||
var so = GetAllyHeroSOBySlot(i);
|
||
if (so == null) continue;
|
||
|
||
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 def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAllEnemiesDefeated) continue;
|
||
UseSkillDefinition(def, i, -1f, null);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var fallbackGroup3 = so.GetPrimarySkillGroup();
|
||
if (fallbackGroup3 != null)
|
||
{
|
||
foreach (var def in fallbackGroup3.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAllEnemiesDefeated) continue;
|
||
UseSkillDefinition(def, i, -1f, null);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var primary3 = so.GetPrimarySkill();
|
||
if (primary3 != null && primary3.triggerCondition == SkillDefinition.SkillTrigger.OnAllEnemiesDefeated)
|
||
{
|
||
UsePrimarySkillForSlot(i, -1f, null);
|
||
}
|
||
}
|
||
}
|
||
|
||
private Dictionary<string, float> _lastOnNoteHitTriggerTime = new Dictionary<string, float>();
|
||
|
||
// track processed unique note IDs (e.g. long-hold note id) so shared effects (mana/hp) are applied only once
|
||
private Dictionary<string, float> _processedNoteHitTimestamps = new Dictionary<string, float>();
|
||
|
||
// Called when a note on a particular track is hit (judgeResult e.g. "Perfect"/"Great"/"Good").
|
||
// Will cast the primary skill for the ally in that track if its primary skill is configured to trigger on note hit.
|
||
// noteType indicates whether the hit event came from a Tap or Hold (tail)
|
||
public bool NotifyNoteHit(int trackIndex, string judgeResult, SkillDefinition.NoteTypeTrigger noteType = SkillDefinition.NoteTypeTrigger.Tap, string uniqueNoteId = null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}");
|
||
// cleanup old processed ids (> 10s)
|
||
float nowCleanup = Time.time;
|
||
_tmpNoteIdRemoval.Clear();
|
||
var toRemove = _tmpNoteIdRemoval;
|
||
foreach (var kv in _processedNoteHitTimestamps)
|
||
{
|
||
if (nowCleanup - kv.Value > 10f) toRemove.Add(kv.Key);
|
||
}
|
||
foreach (var k in toRemove) _processedNoteHitTimestamps.Remove(k);
|
||
|
||
// Determine whether to apply shared effects (mana/H P loss) for this event.
|
||
bool applyShared = true;
|
||
if (!string.IsNullOrEmpty(uniqueNoteId))
|
||
{
|
||
if (_processedNoteHitTimestamps.ContainsKey(uniqueNoteId))
|
||
{
|
||
applyShared = false; // already applied for this hold note
|
||
}
|
||
else
|
||
{
|
||
_processedNoteHitTimestamps[uniqueNoteId] = Time.time;
|
||
}
|
||
}
|
||
|
||
// Always apply shared per-note behavior (mana gain / miss penalty / reserved damage) unless this unique note id was already processed
|
||
if (applyShared)
|
||
{
|
||
try { ApplySharedOnNoteHit(trackIndex, judgeResult); } catch (System.Exception ex) { Debug.LogError($"[SkillBuilder] ApplySharedOnNoteHit threw: {ex}"); }
|
||
}
|
||
|
||
// Dead allies should not trigger any note-hit skills.
|
||
try
|
||
{
|
||
var allyGO = GetAllyObjectBySlot(trackIndex);
|
||
var ally = allyGO != null ? allyGO.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null && ally.IsDead)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: slot {trackIndex} is dead -> ignore skill triggers.");
|
||
return false;
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
if (trackIndex < 0) { LogVerbose("[SkillBuilder] NotifyNoteHit: invalid trackIndex"); return false; }
|
||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||
if (so == null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: no SO found for slot {trackIndex}, attempting fallback resolution...");
|
||
// Dump current UI slot ids for diagnostics
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] teamUIController.allySlotIds = [{string.Join(",", teamUIController.Instance.allySlotIds)}]");
|
||
}
|
||
// Try to find ally GameObject named by convention
|
||
var foundGO = GameObject.Find($"ally_0{trackIndex + 1}");
|
||
if (foundGO != null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] Found GameObject by name ally_0{trackIndex + 1}: {foundGO.name}");
|
||
// try to map to UI slot
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null && ui.allySlotIds != null)
|
||
{
|
||
for (int i = 0; i < ui.allySlotIds.Count; i++)
|
||
{
|
||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||
if (slotObj == null) continue;
|
||
if (slotObj == foundGO || foundGO.transform.IsChildOf(slotObj.transform))
|
||
{
|
||
LogVerbose($"[SkillBuilder] Resolved fallback slot {i} for GameObject {foundGO.name}");
|
||
trackIndex = i; // override
|
||
so = GetAllyHeroSOBySlot(trackIndex);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (so == null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: still no SO for resolved slot {trackIndex}, aborting NotifyNoteHit");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool anyTriggered = false;
|
||
|
||
// If equipped groups exist, iterate equipped groups and check each skill for OnNoteHit
|
||
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 def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnNoteHit) continue;
|
||
|
||
// Respect note type (Tap/Hold/Either) - treat Hold as Tap-equivalent for checking as before
|
||
var effectiveNoteType = noteType;
|
||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold) effectiveNoteType = SkillDefinition.NoteTypeTrigger.Tap;
|
||
if (def.noteTriggerType == SkillDefinition.NoteTypeTrigger.Tap && effectiveNoteType != SkillDefinition.NoteTypeTrigger.Tap) continue;
|
||
if (def.noteTriggerType == SkillDefinition.NoteTypeTrigger.Hold && noteType != SkillDefinition.NoteTypeTrigger.Hold) continue;
|
||
|
||
int quality = JudgeQualityFromString(judgeResult);
|
||
if (def.onNoteHitMinThreshold == SkillDefinition.NoteTriggerThreshold.Miss)
|
||
{
|
||
if (quality != 0) continue;
|
||
}
|
||
else
|
||
{
|
||
int required = (int)def.onNoteHitMinThreshold;
|
||
if (quality < required) continue;
|
||
}
|
||
|
||
// cooldown per slot+skill
|
||
string key = $"{trackIndex}:{def.skillId}";
|
||
float now = Time.time;
|
||
if (def.onNoteHitCooldown > 0f)
|
||
{
|
||
if (_lastOnNoteHitTriggerTime.TryGetValue(key, out float last))
|
||
{
|
||
if (now - last < def.onNoteHitCooldown) continue;
|
||
}
|
||
}
|
||
|
||
// trigger
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||
UseSkillDefinition(def, trackIndex, -1f, null);
|
||
_lastOnNoteHitTriggerTime[key] = now;
|
||
anyTriggered = true;
|
||
}
|
||
}
|
||
return anyTriggered;
|
||
}
|
||
|
||
// Fallback to previous behavior using primary skill (availableSkills)
|
||
var defPrimary = so.GetPrimarySkill();
|
||
if (defPrimary == null) { LogVerbose($"[SkillBuilder] NotifyNoteHit: no primary skill for slot {trackIndex}"); return false; }
|
||
if (defPrimary.triggerCondition != SkillDefinition.SkillTrigger.OnNoteHit)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: primary skill {defPrimary.skillId} for slot {trackIndex} is not set to OnNoteHit (actual={defPrimary.triggerCondition}), rejecting");
|
||
return false;
|
||
}
|
||
|
||
// previous checks preserved
|
||
var effectiveNoteTypePrimary = noteType;
|
||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold) effectiveNoteTypePrimary = SkillDefinition.NoteTypeTrigger.Tap;
|
||
switch (defPrimary.noteTriggerType)
|
||
{
|
||
case SkillDefinition.NoteTypeTrigger.Tap:
|
||
if (effectiveNoteTypePrimary != SkillDefinition.NoteTypeTrigger.Tap) { LogVerbose($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires Tap but event is {noteType}, rejecting"); return false; }
|
||
break;
|
||
case SkillDefinition.NoteTypeTrigger.Hold:
|
||
if (noteType != SkillDefinition.NoteTypeTrigger.Hold) { LogVerbose($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires Hold but event is {noteType}, rejecting"); return false; }
|
||
break;
|
||
case SkillDefinition.NoteTypeTrigger.Either:
|
||
break;
|
||
}
|
||
|
||
int qualityPrimary = JudgeQualityFromString(judgeResult);
|
||
if (defPrimary.onNoteHitMinThreshold == SkillDefinition.NoteTriggerThreshold.Miss)
|
||
{
|
||
if (qualityPrimary != 0) { LogVerbose($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} is Miss-only but quality={qualityPrimary}, rejecting"); return false; }
|
||
}
|
||
else
|
||
{
|
||
int required = (int)defPrimary.onNoteHitMinThreshold;
|
||
if (qualityPrimary < required) { LogVerbose($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires quality>={required} but got {qualityPrimary}, rejecting"); return false; }
|
||
}
|
||
|
||
string keyPrimary = $"{trackIndex}:{defPrimary.skillId}";
|
||
float nowPrimary = Time.time;
|
||
if (defPrimary.onNoteHitCooldown > 0f && _lastOnNoteHitTriggerTime.TryGetValue(keyPrimary, out float lastPrimary))
|
||
{
|
||
if (nowPrimary - lastPrimary < defPrimary.onNoteHitCooldown)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} on cooldown for slot {trackIndex}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering primary skill {defPrimary.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||
UsePrimarySkillForSlot(trackIndex, -1f, null);
|
||
_lastOnNoteHitTriggerTime[keyPrimary] = nowPrimary;
|
||
return true;
|
||
}
|
||
|
||
/*
|
||
Explanation of caster and enemyTarget:
|
||
- GameObject caster: the GameObject that performs (casts) the skill. For ally skills this should be the in-scene ally GameObject (usually name ally_01..ally_05) and should have AllyCombatant / ICombatant components. SkillBuilder uses slotIndex -> GetAllyObjectBySlot to resolve this.
|
||
- GameObject specificTarget / enemyTarget: when a skill targets a specific unit (single enemy or ally), pass that GameObject here. If null, the selected Selector determines the set of targets (e.g. CurrentEnemies without specificTarget will resolve all enemies). Use Enemy GameObjects found by tag 'Enemy' or name 'thisEnemy', or keep a reference to the spawned enemy instance you want.
|
||
|
||
How to assign/choose:
|
||
- Caster: when you call from gameplay code, pass the caster GameObject (or call UseSelectedSkillForSlot with slot index so SkillBuilder resolves caster for you).
|
||
- EnemyTarget: if your skill should hit a specific enemy instance, supply that enemy's GameObject (for example from Enemy spawn manager or from collision detection). If you want to apply to all enemies, pass null and use Selector.CurrentEnemies.
|
||
*/
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void _ally03skill01(EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null)
|
||
{
|
||
AllySlotSkill(2, "skill01", effectType, value, selector, duration, specificTarget);
|
||
}
|
||
|
||
// Coroutine helper for IncreaseManaOverTime
|
||
private IEnumerator ApplyIncreaseManaOverTimeDirect(AllyCombatant ally, float totalAmount, float duration, float tickInterval)
|
||
{
|
||
if (ally == null) yield break;
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
ally.ModifyMana(Mathf.CeilToInt(totalAmount), true, true);
|
||
yield break;
|
||
}
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
ally.ModifyMana(Mathf.CeilToInt(perTick), true, true);
|
||
yield return new WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
|
||
private IEnumerator RemoveBuffAfterDuration(GameObject target, string buffId, float duration)
|
||
{
|
||
if (duration <= 0f) yield break;
|
||
yield return new WaitForSeconds(duration);
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
ic?.RemoveBuff(buffId);
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryScoreEfficiencyChangeDirect(AllyCombatant ally, float delta, float duration, string iconId)
|
||
{
|
||
try
|
||
{
|
||
if (ally == null) yield break;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency + delta);
|
||
yield return new WaitForSeconds(duration);
|
||
if (ally == null) yield break;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - delta);
|
||
}
|
||
finally
|
||
{
|
||
if (ally != null && !string.IsNullOrEmpty(iconId))
|
||
iBudeffPrefabController.Instance?.UnregisterTimedEffect(ally.slotIndex, iconId);
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryAttackChangeDirect(AllyCombatant ally, int delta, float duration, string iconId)
|
||
{
|
||
try
|
||
{
|
||
if (ally == null) yield break;
|
||
ally.ModifyAttack(delta);
|
||
yield return new WaitForSeconds(duration);
|
||
if (ally == null) yield break;
|
||
ally.ModifyAttack(-delta);
|
||
}
|
||
finally
|
||
{
|
||
if (ally != null && !string.IsNullOrEmpty(iconId))
|
||
iBudeffPrefabController.Instance?.UnregisterTimedEffect(ally.slotIndex, iconId);
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryAttackChangeDirect(EnemyCombatant enemy, int delta, float duration, string iconId, int enemyInstanceId)
|
||
{
|
||
try
|
||
{
|
||
if (enemy == null) yield break;
|
||
enemy.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RefreshEnemyNow(enemy);
|
||
yield return new WaitForSeconds(duration);
|
||
if (enemy == null) yield break;
|
||
enemy.ModifyAttack(-delta);
|
||
}
|
||
finally
|
||
{
|
||
if (!string.IsNullOrEmpty(iconId) && enemyInstanceId != 0)
|
||
iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(enemyInstanceId, iconId);
|
||
}
|
||
}
|
||
|
||
// Coroutine helpers for direct DoT/HoT on GameObject targets
|
||
private IEnumerator ApplyDamageOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source, string iconId, int allySlotIndex, int enemyInstanceId)
|
||
{
|
||
try
|
||
{
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null && ic == null)
|
||
{
|
||
Debug.LogWarning($"ApplyDamageOverTimeDirect: target {target.name} cannot receive damage");
|
||
yield break;
|
||
}
|
||
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(totalAmount), true);
|
||
else ic?.ReceiveDamage(totalAmount, source);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (target == null) yield break;
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(perTick), true);
|
||
else ic?.ReceiveDamage(perTick, source);
|
||
yield return new WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (!string.IsNullOrEmpty(iconId))
|
||
{
|
||
if (allySlotIndex >= 0) iBudeffPrefabController.Instance?.UnregisterTimedEffect(allySlotIndex, iconId);
|
||
else if (enemyInstanceId != 0) iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(enemyInstanceId, iconId);
|
||
}
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyHealOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||
{
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null && ic == null)
|
||
{
|
||
Debug.LogWarning($"ApplyHealOverTimeDirect: target {target.name} cannot receive heal");
|
||
yield break;
|
||
}
|
||
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(totalAmount, source);
|
||
else ic?.ReceiveHeal(totalAmount, source);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(perTick, source);
|
||
else ic?.ReceiveHeal(perTick, source);
|
||
yield return new WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
|
||
// Helper: map judge string to numeric quality
|
||
private int JudgeQualityFromString(string judge)
|
||
{
|
||
switch (judge)
|
||
{
|
||
case "Perfect": return 3;
|
||
case "Great": return 2;
|
||
case "Good": return 1;
|
||
default: return 0; // Miss or unknown
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|