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

1426 lines
71 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// SkillBuilder: жɿٵõļܺ
/// - ÿΪһ public ܱҪʩ source, Ŀ specificTarget ȣ
/// - ڲ EffectSystem.Instance.ApplyEffect(...)ͳһʹ Selector EffectType
/// - òamount, duration, tickIntervalԲУ
///
/// ÷ʾ:
/// SkillBuilder.Instance.ExecuteSkill("Fireball", EffectType.DamageSingleEnemy, 120f, Selector.CurrentEnemies, caster, target);
/// SkillBuilder.Instance.ApplyScoreMultiplier(caster, Selector.AllAllies, 1.5f, 5f); // 5 ڶ÷ֳ 1.5
/// </summary>
public class SkillBuilder : MonoBehaviour
{
public static SkillBuilder Instance { get; private set; }
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}");
}
}
// ----------------------------- ģ -----------------------------
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;
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)
{
Debug.Log($"[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);
Debug.Log($"[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>();
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);
Debug.Log($"[SkillBuilder] Applied mana gain {manaGain} to slot {trackIndex} due to judge {judgeResult}");
}
if (judgeResult == "Miss")
{
if (ally != null)
{
float missBase = (levelInfo != null) ? levelInfo.missHpLossBase : missHpLossBase;
// apply damage reduction by damageResistance: final loss = missBase * (1 - damageResistance)
float loss = missBase * (1f - ally.damageResistance);
ally.ModifyHP(-Mathf.CeilToInt(loss), true);
Debug.Log($"[SkillBuilder] Applied miss HP loss {loss} to slot {trackIndex} (damageResistance={ally.damageResistance})");
}
}
else
{
if (damageMult > 0f)
{
int baseAtk = 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);
}
}
}
// ----------------------------- ͨüִнӿ -----------------------------
// ִͨУ, Ч, ֵ, Ŀѡ, ʩ, ѡĿ, ʱ tick
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
Debug.Log($"[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);
}
// ----------------------------- Ŀűʹã EffectSystem Ľһ£ -----------------------------
// ط selector GameObject бܽűвֱӸ ICombatant.Buff
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:
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
break;
case Selector.AllAlliesExceptSelf:
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
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:
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
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;
}
// ----------------------------- Ӱ API -----------------------------
// ֱ޸ȫܷ֣Ч
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();
}
}
// ޸ĵѾķʱ
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);
}
// һĿӦһԵķ/ͷӦã
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);
}
}
// ʱߣ򽵵ͣĿķʣʹ EffectSystem.ScoreMultiplierƼ
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);
}
}
// ----------------------------- бݼʾ -----------------------------
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)
{
ExecuteSkill("ReduceEnemyHealOverTime", EffectType.ReduceEnemyHealOverTime, 0f, 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);
}
// ----------------------------- SO ֵ helpers -----------------------------
// ȡijλ ally GameObject0-based slot index
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;
}
// ݲλ AllyHero_SOʱ Resources в ally_heroID
public AllyHero_SO GetAllyHeroSOBySlot(int slotIndex)
{
// slot-based cache (depends on team selection)
if (_allyHeroSoBySlotCache != null && _allyHeroSoBySlotCache.TryGetValue(slotIndex, out var cached) && cached != null)
return cached;
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return null;
if (slotIndex < 0 || slotIndex >= teamUIController.Instance.allySlotIds.Count) return null;
int id = teamUIController.Instance.allySlotIds[slotIndex];
if (id <= 0) return null;
// 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;
}
// ݵǰѡЧĵȼϢ null ʾδҵ
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;
}
// ȡӢۻʹõǰƥĵȼ attackû򷵻 0
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;
}
// ͳһֵ inputValue == -1 ʱʹ skillStatic + allyAttack
// ֱʹ inputValue EffectType Ҫת
public float ComputeSkillValue(GameObject caster, int slotIndex, float inputValue, int skillStatic)
{
if (inputValue != -1f) return inputValue;
var so = GetAllyHeroSOBySlot(slotIndex);
int allyAtk = GetAllyBaseAttack(so);
return skillStatic + allyAtk;
}
// ----------------------------- λͨýӿ -----------------------------
// ͨڱλĽɫͷʱô˺
// slotIndex: 0-based λskillId: ԶܱʶeffectType,value,selector,durationȲͬǰԼ
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;
}
// determine skill-specific static values (ʾskill01 Ĺ̶˺Ϊ 50)
int skillStaticDamage = 0;
switch (skillId)
{
case "skill01": skillStaticDamage = 50; break;
case "skill_heal_small": skillStaticDamage = 30; break;
// ڴӸֵܾ̬
default: skillStaticDamage = 0; break;
}
float finalValue = ComputeSkillValue(caster, slotIndex, value, skillStaticDamage);
// effectType ǵ˺ϣеʹ CurrentEnemiesselector ɵ÷
ExecuteSkill($"slot{slotIndex + 1}_{skillId}", effectType, finalValue, selector, caster, specificTarget, duration, tickInterval);
}
// 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; }
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;
Debug.Log($"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;
Debug.Log($"UseSkillDefinition: resolved caster via name ally_0{slotIndex + 1} -> {caster.name}");
}
}
// Log useful debug info for diagnosing OnEnemyDead->Self issues
Debug.Log($"[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 vars = new Dictionary<string, float>();
vars["slot"] = slotIndex;
vars["attack"] = GetAllyBaseAttack(so);
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
{
var eff = GetEffectiveLevelInfo(so);
var lvl = eff ?? so.levelStats[0];
vars["maxHP"] = lvl.maxHP;
vars["maxMana"] = lvl.maxMana;
vars["damageResistance"] = lvl.damageResistance;
vars["scoreEfficiency"] = lvl.scoreEfficiency;
vars["attack"] = lvl.attack; // ensure attack from SO levelStats is available
}
else
{
vars["maxHP"] = 0f;
vars["maxMana"] = 0f;
vars["damageResistance"] = 0f;
vars["scoreEfficiency"] = 1f;
}
if (so != null) vars["ally_currentEXP"] = so.ally_currentEXP;
else vars["ally_currentEXP"] = 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;
if (!def.IsSingleInstance)
{
// 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;
}
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;
var ally = t.GetComponent<AllyCombatant>();
var ic = t.GetComponent<ICombatant>();
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
{
StartCoroutine(ApplyDamageOverTimeDirect(t, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster));
}
break;
case EffectType.HealSingleSelf:
case EffectType.HealGroupSingle:
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(amountTotal), true);
else if (ic != null) ic.ReceiveHeal(amountTotal, caster);
break;
case EffectType.HealOverTimeSelf:
case EffectType.HealGroupOverTime:
if (def.defaultDuration <= 0f)
{
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(amountPerTick), true);
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:
var deb = new Buff { buffId = System.Guid.NewGuid().ToString(), duration = def.defaultDuration, healReceivedMultiplier = 0.5f };
if (ic != null) ic.ApplyBuff(deb, caster);
break;
case EffectType.BuffDuration:
case EffectType.DebuffDuration:
var b = new Buff { buffId = System.Guid.NewGuid().ToString(), duration = def.defaultDuration };
if (def.effectType == EffectType.BuffDuration) b.scoreMultiplier = 1.5f;
if (ic != null) ic.ApplyBuff(b, caster);
break;
// effect type
case EffectType.IncreaseMaxHP:
if (ally != null) ally.SetMaxHP(ally.maxHP + Mathf.CeilToInt(amountTotal), false);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy)) enemy.maxHP += Mathf.CeilToInt(amountTotal);
break;
case EffectType.DecreaseMaxHP:
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP - Mathf.CeilToInt(amountTotal)), false);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy2)) enemy2.maxHP = Mathf.Max(1, enemy2.maxHP - Mathf.CeilToInt(amountTotal));
break;
case EffectType.IncreaseMaxMana:
if (ally != null) ally.SetMaxMana(ally.maxMana + Mathf.CeilToInt(amountTotal), false);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy3)) enemy3.maxMana += Mathf.CeilToInt(amountTotal);
break;
case EffectType.DecreaseMaxMana:
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana - Mathf.CeilToInt(amountTotal)), false);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy4)) enemy4.maxMana = Mathf.Max(1, enemy4.maxMana - Mathf.CeilToInt(amountTotal));
break;
case EffectType.IncreaseScoreEfficiency:
if (ally != null) ally.scoreEfficiency += amountTotal;
break;
case EffectType.DecreaseScoreEfficiency:
if (ally != null) ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
break;
case EffectType.IncreaseAttack:
if (ally != null) ally.attack += Mathf.CeilToInt(amountTotal);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy5)) enemy5.attack += Mathf.CeilToInt(amountTotal);
break;
case EffectType.DecreaseAttack:
if (ally != null) ally.attack = Mathf.Max(0, ally.attack - Mathf.CeilToInt(amountTotal));
else if (t.TryGetComponent<EnemyCombatant>(out var enemy6)) enemy6.attack = Mathf.Max(0, enemy6.attack - Mathf.CeilToInt(amountTotal));
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
{
// 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);
Debug.Log($"[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)
{
Debug.Log($"[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)
{
Debug.Log($"[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)
{
Debug.Log($"[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;
Debug.Log($"[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;
Debug.Log($"[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)
{
Debug.Log($"[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;
Debug.Log($"[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;
Debug.Log($"[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)
{
Debug.Log($"[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)
{
Debug.Log($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}");
// cleanup old processed ids (> 10s)
float nowCleanup = Time.time;
var toRemove = new List<string>();
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}"); }
}
if (trackIndex < 0) { Debug.Log("[SkillBuilder] NotifyNoteHit: invalid trackIndex"); return false; }
var so = GetAllyHeroSOBySlot(trackIndex);
if (so == null)
{
Debug.Log($"[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)
{
Debug.Log($"[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)
{
Debug.Log($"[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))
{
Debug.Log($"[SkillBuilder] Resolved fallback slot {i} for GameObject {foundGO.name}");
trackIndex = i; // override
so = GetAllyHeroSOBySlot(trackIndex);
break;
}
}
}
}
if (so == null)
{
Debug.Log($"[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
Debug.Log($"[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) { Debug.Log($"[SkillBuilder] NotifyNoteHit: no primary skill for slot {trackIndex}"); return false; }
if (defPrimary.triggerCondition != SkillDefinition.SkillTrigger.OnNoteHit)
{
Debug.Log($"[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) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires Tap but event is {noteType}, rejecting"); return false; }
break;
case SkillDefinition.NoteTypeTrigger.Hold:
if (noteType != SkillDefinition.NoteTypeTrigger.Hold) { Debug.Log($"[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) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} is Miss-only but quality={qualityPrimary}, rejecting"); return false; }
}
else
{
int required = (int)defPrimary.onNoteHitMinThreshold;
if (qualityPrimary < required) { Debug.Log($"[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)
{
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} on cooldown for slot {trackIndex}");
return false;
}
}
Debug.Log($"[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.
*/
// ----------------------------- ʾΪ3 װһݺûʾ -----------------------------
// 3(2) ļ1ѭûǩ_ally03skill01(EffectType, value, Selector, duration)
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;
}
}
// Coroutine helpers for direct DoT/HoT on GameObject targets
private IEnumerator ApplyDamageOverTimeDirect(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($"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 (ally != null) ally.ModifyHP(-Mathf.CeilToInt(perTick), true);
else ic?.ReceiveDamage(perTick, source);
yield return new WaitForSeconds(tickInterval);
elapsed += tickInterval;
}
}
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.ModifyHP(Mathf.CeilToInt(totalAmount), true);
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.ModifyHP(Mathf.CeilToInt(perTick), true);
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
}
}
}