超大量的更新 修复很多问题,gameplay特效初步

This commit is contained in:
FloatGaming
2026-02-14 23:46:20 +08:00
parent ef8eb67259
commit 3a7a0b4669
360 changed files with 85670 additions and 4144 deletions
+653 -74
View File
@@ -1,21 +1,24 @@
using System.Collections;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// SkillBuilder: жɿٵõļܺ?
/// - ÿΪһ public ܱҪʩ source, Ŀ specificTarget ȣ
/// - ڲ EffectSystem.Instance.ApplyEffect(...)ͳһʹ Selector EffectType
/// - òamount, duration, tickIntervalԲУ
/// 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);
/// SkillBuilder.Instance.ApplyScoreMultiplier(caster, Selector.AllAllies, 1.5f, 5f); // 5 ڶ÷ֳ?1.5
/// 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;
@@ -38,9 +41,13 @@ public class SkillBuilder : MonoBehaviour
{
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;
@@ -67,9 +74,46 @@ public class SkillBuilder : MonoBehaviour
// 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;
}
private void PrewarmAllyHeroSOIndex()
{
if (_allAllyHeroSOs != null && _allAllyHeroSOs.Length > 0) return;
@@ -116,6 +160,7 @@ public class SkillBuilder : MonoBehaviour
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;
@@ -153,18 +198,43 @@ public class SkillBuilder : MonoBehaviour
{
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;
}
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);
LogVerbose($"[SkillBuilder] Applied miss HP loss {loss} to slot {trackIndex} (damageResistance={ally.damageResistance})");
// 播放敌人攻击特效,并将伤害逻辑延迟到特效到达时执行
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 = GetAllyBaseAttack(so);
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);
@@ -172,8 +242,8 @@ public class SkillBuilder : MonoBehaviour
}
}
// ----------------------------- ͨüִнӿ -----------------------------
// ִͨУ, Ч, ֵ, Ŀѡ, ʩ, ѡĿ, ʱ?tick
// 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)
@@ -246,8 +316,8 @@ public class SkillBuilder : MonoBehaviour
return _cachedAllies;
}
// ----------------------------- Ŀűʹã?EffectSystem Ľһ£ -----------------------------
// ط selector GameObject бܽűвֱӸ ICombatant.Buff
// Documentation text normalized.
// Documentation text normalized.
public List<GameObject> ResolveTargetsLocal(Selector selector, GameObject source = null, GameObject specificTarget = null)
{
List<GameObject> list = new List<GameObject>();
@@ -342,8 +412,8 @@ public class SkillBuilder : MonoBehaviour
return uniq;
}
// ----------------------------- Ӱ API -----------------------------
// ֱ޸ȫܷ֣Ч
// Documentation text normalized.
// Documentation text normalized.
public void ModifyTotalScore(int delta)
{
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance is null"); return; }
@@ -357,7 +427,7 @@ public class SkillBuilder : MonoBehaviour
}
}
// ޸ĵѾķʱ
// Documentation text normalized.
public void ModifySingleAllyScoreDirect(GameObject allyObject, int delta)
{
if (allyObject == null) return;
@@ -377,7 +447,7 @@ public class SkillBuilder : MonoBehaviour
if (ally != null) ally.AddScoreDirect(delta);
}
// һĿӦһԵķ/ͷӦã
// Documentation text normalized.
public void ModifyGroupScoreDirect(Selector selector, GameObject caster, int delta)
{
var targets = ResolveTargetsLocal(selector, caster);
@@ -388,7 +458,7 @@ public class SkillBuilder : MonoBehaviour
}
}
// ʱߣ򽵵ͣĿķʣʹ EffectSystem.ScoreMultiplierƼ
// Documentation text normalized.
public void ApplyScoreMultiplier(Selector selector, GameObject caster, float multiplier, float duration)
{
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplier: invalid multiplier"); return; }
@@ -435,7 +505,7 @@ public class SkillBuilder : MonoBehaviour
}
}
// ----------------------------- бݼʾ?-----------------------------
// Documentation text normalized.
public void DealSingleEnemyDamage(GameObject caster, GameObject enemyTarget, float amount)
{
if (enemyTarget == null) { Debug.LogWarning("DealSingleEnemyDamage: enemyTarget == null"); return; }
@@ -510,7 +580,9 @@ public class SkillBuilder : MonoBehaviour
public void ReduceEnemyHealOverTime(GameObject caster, Selector selector, float duration, GameObject specificTarget = null)
{
ExecuteSkill("ReduceEnemyHealOverTime", EffectType.ReduceEnemyHealOverTime, 0f, selector, caster, specificTarget, duration);
// 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)
@@ -534,8 +606,8 @@ public class SkillBuilder : MonoBehaviour
HealGroupOverTime(caster, defaultHeal * 5f, 4f, 1f, true);
}
// ----------------------------- SO ֵ helpers -----------------------------
// ȡijλ ally GameObject0-based slot index
// Documentation text normalized.
// Documentation text normalized.
public GameObject GetAllyObjectBySlot(int slotIndex)
{
var ui = teamUIController.Instance;
@@ -544,17 +616,24 @@ public class SkillBuilder : MonoBehaviour
return named;
}
// ݲλ AllyHero_SOʱ Resources в ally_heroID
// 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)
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;
{
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();
@@ -582,7 +661,7 @@ public class SkillBuilder : MonoBehaviour
return result;
}
// ݵǰѡЧĵȼϢ null ʾδҵ
// Documentation text normalized.
private AllyHero_SO.AllyLevelInfo GetEffectiveLevelInfo(AllyHero_SO so)
{
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
@@ -605,7 +684,7 @@ public class SkillBuilder : MonoBehaviour
return best;
}
// ȡӢۻʹõǰƥĵȼ?attackû򷵻 0
// Documentation text normalized.
public int GetAllyBaseAttack(AllyHero_SO so)
{
if (so == null) return 0;
@@ -615,19 +694,20 @@ public class SkillBuilder : MonoBehaviour
return 0;
}
// ͳһֵ?inputValue == -1 ʱʹ skillStatic + allyAttack
// ֱʹ inputValue?EffectType Ҫת
// 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);
int allyAtk = GetAllyBaseAttack(so);
var ally = caster != null ? caster.GetComponent<AllyCombatant>() : null;
int allyAtk = ally != null ? ally.attack : GetAllyBaseAttack(so);
return skillStatic + allyAtk;
}
// ----------------------------- λͨýӿ -----------------------------
// ͨڱλĽɫͷʱô˺
// slotIndex: 0-based λskillId: ԶܱʶeffectType,value,selector,durationȲͬǰԼ
// 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);
@@ -637,26 +717,347 @@ public class SkillBuilder : MonoBehaviour
return;
}
// determine skill-specific static values (ʾskill01 Ĺ̶˺Ϊ 50)
// 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);
// effectType ǵ˺ϣеʹ CurrentEnemiesselector ɵ÷
// 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 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;
}
_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;
switch (effectType)
{
case EffectType.IncreaseScoreEfficiency:
case EffectType.DecreaseScoreEfficiency:
if (ally == null) return false;
float scoreDelta = effectType == EffectType.IncreaseScoreEfficiency ? amount : -amount;
ally.scoreEfficiency = Mathf.Max(0f, 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;
if (ally != null) ally.damageResistance = Mathf.Clamp01(ally.damageResistance + resistDelta);
else enemy.damageResistance = Mathf.Clamp01(enemy.damageResistance + 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;
if (ally != null) ally.ModifyAttack(atkDelta);
else enemy.ModifyAttack(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;
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP + hpDelta), false);
else enemy.SetMaxHP(Mathf.Max(1, enemy.maxHP + hpDelta), false);
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;
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana + manaDelta), false);
else enemy.SetMaxMana(Mathf.Max(1, enemy.maxMana + manaDelta), false);
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = manaDelta };
return true;
}
return false;
}
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 = Mathf.Clamp01(ally.damageResistance - state.floatDelta);
else enemy.damageResistance = Mathf.Clamp01(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;
}
}
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; }
// Resolve group/icon metadata for the ally HUD skill icon queue.
try
{
var heroSoForName = GetAllyHeroSOBySlot(slotIndex);
// Documentation text normalized.
int groupId = 0;
string smallSkillName = null;
Sprite smallSkillIcon = null;
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;
// 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)
{
@@ -708,26 +1109,46 @@ public class SkillBuilder : MonoBehaviour
var vars = _varsBuffer;
vars.Clear();
vars["slot"] = slotIndex;
vars["attack"] = GetAllyBaseAttack(so);
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
// 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;
vars["attack"] = lvl.attack; // ensure attack from SO levelStats is available
}
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["maxHP"] = 0f;
vars["maxMana"] = 0f;
vars["damageResistance"] = 0f;
vars["scoreEfficiency"] = 1f;
vars["level"] = 0f;
vars["levelID"] = 0f;
vars["currentLevel"] = 0f;
@@ -762,7 +1183,19 @@ public class SkillBuilder : MonoBehaviour
// amount now represents the formula result (per-tick for sustained effects, instant for single-instance)
float amountPerTick = amount;
float amountTotal = amountPerTick;
if (!def.IsSingleInstance)
// 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();
@@ -778,6 +1211,20 @@ public class SkillBuilder : MonoBehaviour
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
@@ -821,14 +1268,14 @@ public class SkillBuilder : MonoBehaviour
break;
case EffectType.HealSingleSelf:
case EffectType.HealGroupSingle:
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(amountTotal), true);
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.ModifyHP(Mathf.CeilToInt(amountPerTick), true);
if (ally != null) ally.ReceiveHeal(amountPerTick, caster);
else ic?.ReceiveHeal(amountPerTick, caster);
}
else
@@ -852,45 +1299,108 @@ public class SkillBuilder : MonoBehaviour
}
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);
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:
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);
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;
// effect type
// Documentation text normalized.
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);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy)) enemy.SetMaxHP(enemy.maxHP + Mathf.CeilToInt(amountTotal), false);
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));
else if (t.TryGetComponent<EnemyCombatant>(out var enemy2)) enemy2.SetMaxHP(Mathf.Max(1, enemy2.maxHP - Mathf.CeilToInt(amountTotal)), false);
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);
else if (t.TryGetComponent<EnemyCombatant>(out var enemy3)) enemy3.SetMaxMana(enemy3.maxMana + Mathf.CeilToInt(amountTotal), false);
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));
else if (t.TryGetComponent<EnemyCombatant>(out var enemy4)) enemy4.SetMaxMana(Mathf.Max(1, enemy4.maxMana - Mathf.CeilToInt(amountTotal)), false);
break;
case EffectType.IncreaseScoreEfficiency:
if (ally != null) ally.scoreEfficiency += amountTotal;
if (ally != null)
{
if (def.defaultDuration <= 0f)
{
ally.scoreEfficiency += amountTotal;
}
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
{
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, amountTotal, def.defaultDuration));
}
}
break;
case EffectType.DecreaseScoreEfficiency:
if (ally != null) ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
if (ally != null)
{
if (def.defaultDuration <= 0f) ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, -amountTotal, def.defaultDuration));
}
break;
case EffectType.IncreaseAttack:
if (ally != null) ally.ModifyAttack(Mathf.CeilToInt(amountTotal));
else if (t.TryGetComponent<EnemyCombatant>(out var enemy5)) enemy5.attack += Mathf.CeilToInt(amountTotal);
{
int delta = Mathf.CeilToInt(amountTotal);
if (ally != null)
{
if (def.defaultDuration <= 0f) ally.ModifyAttack(delta);
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, delta, def.defaultDuration));
}
else if (t.TryGetComponent<EnemyCombatant>(out var enemy5))
{
if (def.defaultDuration <= 0f) enemy5.ModifyAttack(delta);
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy5, delta, def.defaultDuration));
}
}
break;
case EffectType.DecreaseAttack:
if (ally != null) ally.ModifyAttack(-Mathf.CeilToInt(amountTotal));
else if (t.TryGetComponent<EnemyCombatant>(out var enemy6)) enemy6.attack = Mathf.Max(0, enemy6.attack - Mathf.CeilToInt(amountTotal));
{
int delta = Mathf.CeilToInt(amountTotal);
if (ally != null)
{
if (def.defaultDuration <= 0f) ally.ModifyAttack(-delta);
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, -delta, def.defaultDuration));
}
else if (t.TryGetComponent<EnemyCombatant>(out var enemy6))
{
if (def.defaultDuration <= 0f) enemy6.ModifyAttack(-delta);
else if (!TryApplyRefreshOnlyTimedEffect(def, t, amountTotal, def.defaultDuration))
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy6, -delta, def.defaultDuration));
}
}
break;
case EffectType.RedirectNextDamageToSelf:
if (ally != null) AllyCombatant.ActivateNextDamageRedirect(ally, def.defaultDuration);
@@ -908,6 +1418,26 @@ public class SkillBuilder : MonoBehaviour
{
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 (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);
}
@@ -1249,6 +1779,19 @@ public class SkillBuilder : MonoBehaviour
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)
@@ -1405,8 +1948,8 @@ public class SkillBuilder : MonoBehaviour
- 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.
*/
// ----------------------------- ʾΪ? װһݺûʾ?-----------------------------
// ?(2) ļ1ѭûǩ_ally03skill01(EffectType, value, Selector, duration)
// 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);
@@ -1432,6 +1975,42 @@ public class SkillBuilder : MonoBehaviour
}
}
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)
{
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);
}
private IEnumerator ApplyTemporaryAttackChangeDirect(AllyCombatant ally, int delta, float duration)
{
if (ally == null) yield break;
ally.ModifyAttack(delta);
yield return new WaitForSeconds(duration);
if (ally == null) yield break;
ally.ModifyAttack(-delta);
}
private IEnumerator ApplyTemporaryAttackChangeDirect(EnemyCombatant enemy, int delta, float duration)
{
if (enemy == null) yield break;
enemy.ModifyAttack(delta);
yield return new WaitForSeconds(duration);
if (enemy == null) yield break;
enemy.ModifyAttack(-delta);
}
// Coroutine helpers for direct DoT/HoT on GameObject targets
private IEnumerator ApplyDamageOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
{
@@ -1476,7 +2055,7 @@ public class SkillBuilder : MonoBehaviour
if (duration <= 0f || tickInterval <= 0f)
{
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(totalAmount), true);
if (ally != null) ally.ReceiveHeal(totalAmount, source);
else ic?.ReceiveHeal(totalAmount, source);
yield break;
}
@@ -1486,7 +2065,7 @@ public class SkillBuilder : MonoBehaviour
float elapsed = 0f;
while (elapsed < duration)
{
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(perTick), true);
if (ally != null) ally.ReceiveHeal(perTick, source);
else ic?.ReceiveHeal(perTick, source);
yield return new WaitForSeconds(tickInterval);
elapsed += tickInterval;