4003 lines
177 KiB
C#
4003 lines
177 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
/// <summary>
|
||
/// SkillBuilder.Instance.ExecuteSkill("Fireball", EffectType.DamageSingleEnemy, 120f, Selector.CurrentEnemies, caster, target);
|
||
/// Documentation text normalized.
|
||
/// </summary>
|
||
public class SkillBuilder : MonoBehaviour
|
||
{
|
||
public static SkillBuilder Instance { get; private set; }
|
||
|
||
// Dedup UI feed so one small-skill (group) that uses multiple SkillDefinitions only prints once per frame.
|
||
private static readonly Dictionary<long, int> s_lastSkillFeedFrameByKey = new Dictionary<long, int>(128);
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance == null) Instance = this;
|
||
else
|
||
{
|
||
Destroy(gameObject);
|
||
return;
|
||
}
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
// Prewarm expensive Resources.LoadAll<AllyHero_SO>("") so first note hit doesn't hitch.
|
||
// This keeps behavior the same, just moves the cost into loading time.
|
||
try
|
||
{
|
||
PrewarmAllyHeroSOIndex();
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
Debug.LogWarning($"[SkillBuilder] PrewarmAllyHeroSOIndex failed: {ex}");
|
||
}
|
||
|
||
// Reset in-game skill trigger feed each play session.
|
||
try { SkillTriggerFeedUI.Clear(); } catch { }
|
||
try { s_lastSkillFeedFrameByKey.Clear(); } catch { }
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public float defaultDamage = 100f;
|
||
public float defaultHeal = 80f;
|
||
public float defaultDuration = 5f;
|
||
public float defaultTickInterval = 1f;
|
||
|
||
[Header("Shared note-hit auto-skill settings")]
|
||
// These defaults remain as a global fallback but the preferred source is per-level fields in AllyHero_SO
|
||
[Tooltip("(Fallback) Mana gained on Good judge if SO level value not present")] public int manaGainGood = 1;
|
||
[Tooltip("(Fallback) Mana gained on Great judge if SO level value not present")] public int manaGainGreat = 2;
|
||
[Tooltip("(Fallback) Mana gained on Perfect judge if SO level value not present")] public int manaGainPerfect = 3;
|
||
[Tooltip("(Fallback) Mana gained on Miss judge if SO level value not present")] public int manaGainOnMiss = 5;
|
||
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Good hits if SO level value not present")] public float damageMultiplierGood = 0.5f;
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Great hits if SO level value not present")] public float damageMultiplierGreat = 0.75f;
|
||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Perfect hits if SO level value not present")] public float damageMultiplierPerfect = 1f;
|
||
|
||
[Tooltip("(Fallback) 若 SO 等级数据未提供,则 Miss 基本生命损失 = missHpLossBase * (1 - damageResistance)")] public float missHpLossBase = 10f;
|
||
|
||
// --------- Caches to avoid first-hit hitch (Resources.LoadAll) ---------
|
||
private AllyHero_SO[] _allAllyHeroSOs;
|
||
private Dictionary<int, AllyHero_SO> _allyHeroSoById;
|
||
private Dictionary<int, AllyHero_SO> _allyHeroSoBySlotCache;
|
||
|
||
// Reusable buffers to reduce allocations during gameplay
|
||
private readonly Dictionary<string, float> _varsBuffer = new Dictionary<string, float>(16);
|
||
private readonly List<string> _tmpNoteIdRemoval = new List<string>(16);
|
||
// (slot, skillId) tuple key avoids a string concat on the repeat-window path.
|
||
private readonly Dictionary<(int, string), float> _lastSkillTriggerTime = new Dictionary<(int, 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 readonly Dictionary<int, ManaFullLongingState> _manaFullLongingStateBySlot = new Dictionary<int, ManaFullLongingState>(8);
|
||
private readonly Dictionary<int, EquipDuelState> _equipDuelStatesBySlot = new Dictionary<int, EquipDuelState>(8);
|
||
private readonly Dictionary<int, int> _equipManaFullSpendCountBySlot = new Dictionary<int, int>(8);
|
||
private readonly HashSet<int> _equipGhoulSchoolActiveSlots = new HashSet<int>();
|
||
private readonly Dictionary<int, HashSet<string>> _equipGhoulSchoolSeenSkillIdsBySlot = new Dictionary<int, HashSet<string>>(8);
|
||
private readonly HashSet<int> _equipThousandthActiveSlots = new HashSet<int>();
|
||
private readonly Dictionary<int, float> _equipThousandthAppliedBonusBySlot = new Dictionary<int, float>(8);
|
||
private readonly HashSet<int> _equipForgetfulRhythmActiveSlots = new HashSet<int>();
|
||
private readonly Dictionary<int, HashSet<string>> _equipForgetfulRhythmSeenSkillIdsBySlot = new Dictionary<int, HashSet<string>>(8);
|
||
private readonly Dictionary<int, int> _equipForgetfulRhythmStacksBySlot = new Dictionary<int, int>(8);
|
||
private readonly Dictionary<int, string> _equipTalentScoutLastBorrowedSkillBySlot = new Dictionary<int, string>(8);
|
||
private readonly Dictionary<int, Coroutine> _equipTalentScoutDisagreeCoroutinesBySlot = new Dictionary<int, Coroutine>(8);
|
||
private readonly Dictionary<int, float> _equipTalentScoutDisagreeRestoreScoreBySlot = new Dictionary<int, float>(8);
|
||
private readonly Dictionary<int, int> _equipOutOfLineKillCountsBySlot = new Dictionary<int, int>(8);
|
||
private readonly Dictionary<int, int> _equipStormStacksBySlot = new Dictionary<int, int>(8);
|
||
private readonly HashSet<int> _equipStormProcessingSlots = new HashSet<int>();
|
||
private readonly HashSet<int> _equipFateRelianceActiveSlots = new HashSet<int>();
|
||
private readonly Dictionary<int, int> _equipNewIdeaStacksBySlot = new Dictionary<int, int>(8);
|
||
private readonly Dictionary<int, List<Coroutine>> _equipNewIdeaExpireCoroutinesBySlot = new Dictionary<int, List<Coroutine>>(8);
|
||
private readonly HashSet<int> _equipSocialMaskActiveSlots = new HashSet<int>();
|
||
private readonly Dictionary<int, int> _equipSocialMaskOriginalMaxHpBySlot = new Dictionary<int, int>(8);
|
||
private readonly Dictionary<int, float> _equipSocialMaskOriginalScoreBySlot = new Dictionary<int, float>(8);
|
||
private readonly HashSet<int> _equipFutureActiveSlots = new HashSet<int>();
|
||
private bool _equipQuietTurnBroadcastInProgress;
|
||
private int _cachedAlliesFrame = -1;
|
||
private readonly List<GameObject> _cachedAllies = new List<GameObject>(8);
|
||
|
||
private const string SkillIdYuetaoLongingDamage = "44109";
|
||
private const string SkillIdYuetaoLongingCost = "44109_2";
|
||
private const string EquipSkillLostMaster = "420001_overheal_mana";
|
||
private const string EquipSkillDuelStart = "420002_duel_start";
|
||
private const string EquipSkillDuelDouble = "420003_duel_double";
|
||
private const string EquipSkillDuelDisable = "420004_duel_disable";
|
||
private const string EquipSkillGhoulSchool = "420005_ghoul_school";
|
||
private const string EquipSkillFifthManaRestore = "420006_fifth_mana_restore";
|
||
private const string EquipSkillMaxHpToAttack = "420007_maxhp_gain_attack";
|
||
private const string EquipSkillSelfDamagedGrowHp = "420008_self_damage_grow_hp";
|
||
private const string EquipSkillAdjacentDamagedAddScore = "420009_adjacent_damage_add_score";
|
||
private const string EquipSkillStartSetMana = "420010_start_set_mana";
|
||
private const string EquipSkillKillRefillMana = "420011_kill_refill_mana";
|
||
private const string EquipSkillManaSpendReduceCap = "420012_mana_spend_reduce_cap";
|
||
private const string EquipSkillVacantPerfectMana = "420013_vacant_perfect_mana";
|
||
private const string EquipSkillVacantMissHeal = "420014_vacant_miss_heal";
|
||
private const string EquipSkillThousandthTracker = "420015_thousandth_tracker";
|
||
private const string EquipSkillForgetfulRhythm = "420016_forgetful_rhythm";
|
||
private const string EquipSkillTalentScout = "420017_talent_scout";
|
||
private const string EquipSkillOutOfLineManaScore = "420018_out_of_line_mana_score";
|
||
private const string EquipSkillOutOfLineEnemyDead = "420019_out_of_line_enemy_dead";
|
||
private const string EquipSkillStormStart = "420020_storm_start";
|
||
private const string EquipSkillMissionStart = "420021_mission_start";
|
||
private const string EquipSkillMissionFullMana = "420022_mission_full_mana";
|
||
private const string EquipSkillAllForYouMana = "420023_all_for_you_mana";
|
||
private const string EquipSkillAllForYouKill = "420024_all_for_you_kill";
|
||
private const string EquipSkillFateRelianceStart = "420025_fate_reliance_start";
|
||
private const string EquipSkillNewIdeaKill = "420026_new_idea_kill";
|
||
private const string EquipSkillNewIdeaClear = "420027_new_idea_clear";
|
||
private const string EquipSkillNamelessLaneKill = "420028_nameless_lane_kill";
|
||
private const string EquipSkillSocialMaskStart = "420029_social_mask_start";
|
||
private const string EquipSkillSocialMaskKill = "420030_social_mask_kill";
|
||
private const string EquipSkillSocialMaskClear = "420031_social_mask_clear";
|
||
private const string EquipSkillQuietTurn = "420032_quiet_turn";
|
||
private const string EquipSkillBlock = "420033_block";
|
||
private const string EquipSkillFutureStart = "420034_future_start";
|
||
private const string EquipSkillFutureHeal = "420035_future_heal";
|
||
private const string EquipSkillFutureClear = "420036_future_clear";
|
||
private const string EquipSkillQuietTurnHealAdjacent = "420037_quiet_turn_heal_adjacent";
|
||
private const string EquipSkillBlockStart = "420038_block_start";
|
||
private const string IllusionSkillBole14StartMaxHp = "421001_bole14_start_maxhp";
|
||
private const string IllusionSkillBole6SpendFullMaxHp = "421002_bole6_spend_full_maxhp";
|
||
private const string IllusionSkillScarborough28StartAttack = "421003_scarborough28_start_attack";
|
||
private const string IllusionSkillScarborough33SpendFullScore = "421004_scarborough33_spend_full_score";
|
||
private const string IllusionSkillBole7KillHeal = "421005_bole7_kill_heal";
|
||
private const string IllusionSkillCraft9SpendFullMana = "421006_craft9_spend_full_mana";
|
||
private const string IllusionSkillTeraExtraSlot = "421007_tera_extra_slot";
|
||
|
||
private struct ManaFullLongingState
|
||
{
|
||
public int frame;
|
||
public int consumedHp;
|
||
}
|
||
|
||
private sealed class EquipDuelState
|
||
{
|
||
public bool disabled;
|
||
public int hpLossPerSecond = 12;
|
||
public int scorePerSecond = 6;
|
||
public Coroutine routine;
|
||
}
|
||
|
||
private static readonly HashSet<string> s_refreshOnlyTimedSkillIds = new HashSet<string>
|
||
{
|
||
// Lock
|
||
"44305_2", // Lock_TurnTables (attack up, timed)
|
||
"44306_2", // Lock_Mine (score efficiency up, timed)
|
||
"44308", // Lock_Accomplice (damage resistance up, timed)
|
||
|
||
// MocaiLi
|
||
"44202", // HoldLine (damage resistance up, timed)
|
||
"44203", // LockOn (enemy damage resistance down, timed)
|
||
"44204", // Heartbeat (enemy attack down, timed)
|
||
"44208", // BloodInspire (score efficiency up, timed)
|
||
|
||
// Winnie
|
||
"44403", // Inspiration (score efficiency up, timed)
|
||
"44404", // EasyTask (score efficiency up, timed)
|
||
"44408", // ContributeCreate (max HP up, timed)
|
||
};
|
||
|
||
private static readonly HashSet<string> s_refreshOnlyOverTimeSkillIds = new HashSet<string>
|
||
{
|
||
// MocaiLi LastHope pair
|
||
"44210_1", // Heal over time
|
||
"44210_2", // Mana over time (can be negative)
|
||
};
|
||
|
||
private sealed class RefreshOnlyTimedState
|
||
{
|
||
public EffectType effectType;
|
||
public float floatDelta;
|
||
public int intDelta;
|
||
public string budeffIconId;
|
||
public int budeffSlotIndex = -1;
|
||
public int budeffEnemyInstanceId;
|
||
// For attack buffs: the per-source key used with SetAttackModifier/ClearAttackModifier,
|
||
// so revert clears exactly this source's contribution instead of baking into base.
|
||
public string attackModifierKey;
|
||
}
|
||
|
||
private void PrewarmAllyHeroSOIndex()
|
||
{
|
||
if (_allAllyHeroSOs != null && _allAllyHeroSOs.Length > 0) return;
|
||
|
||
_allAllyHeroSOs = RuntimeResourcesCache.LoadAllAllyHeroes();
|
||
_allyHeroSoById = new Dictionary<int, AllyHero_SO>(_allAllyHeroSOs != null ? _allAllyHeroSOs.Length : 0);
|
||
_allyHeroSoBySlotCache = new Dictionary<int, AllyHero_SO>(8);
|
||
|
||
if (_allAllyHeroSOs != null)
|
||
{
|
||
foreach (var so in _allAllyHeroSOs)
|
||
{
|
||
if (so == null) continue;
|
||
if (so.ally_heroID <= 0) continue;
|
||
if (!_allyHeroSoById.ContainsKey(so.ally_heroID))
|
||
_allyHeroSoById.Add(so.ally_heroID, so);
|
||
}
|
||
}
|
||
|
||
if (GameConfig.verboseLogs)
|
||
{
|
||
LogVerbose($"[SkillBuilder] PrewarmAllyHeroSOIndex: loaded {_allAllyHeroSOs?.Length ?? 0} AllyHero_SO assets, indexed {_allyHeroSoById.Count}");
|
||
}
|
||
}
|
||
|
||
// Placeholder: if you want to route damage through EffectSystem later, use this.
|
||
public void DealDamageFromAllyToEnemies(int slotIndex, float amount)
|
||
{
|
||
// Resolve caster GameObject and route damage to EffectSystem so enemies receive damage with resistances etc.
|
||
var caster = GetAllyObjectBySlot(slotIndex);
|
||
if (global::EffectSystem.Instance == null)
|
||
{
|
||
Debug.LogWarning($"DealDamageFromAllyToEnemies: EffectSystem.Instance is null. slot={slotIndex} amount={amount}");
|
||
return;
|
||
}
|
||
// Apply as single-instance damage to current enemies; EffectSystem will pick first applicable enemy for DamageSingleEnemy
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null);
|
||
if (GameConfig.verboseLogs)
|
||
LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem");
|
||
}
|
||
|
||
private void ApplySharedOnNoteHit(int trackIndex, string judgeResult)
|
||
{
|
||
if (trackIndex < 0) return;
|
||
var allyGO = GetAllyObjectBySlot(trackIndex);
|
||
if (allyGO == null) return;
|
||
var ally = allyGO.GetComponent<AllyCombatant>();
|
||
if (ally != null && (ally.IsDead || ally.maxHP == 0)) return; // dead or empty allies should not gain mana / take miss damage / deal note-hit damage
|
||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||
// try to obtain per-level params from SO if present
|
||
AllyHero_SO.AllyLevelInfo levelInfo = so != null ? (so.GetEffectiveLevelForCurrentEXP()) : null;
|
||
|
||
int manaGain = 0;
|
||
float damageMult = 0f;
|
||
if (levelInfo != null)
|
||
{
|
||
switch (judgeResult)
|
||
{
|
||
case "Perfect": manaGain = levelInfo.manaGainPerfect; damageMult = levelInfo.damageMultiplierPerfect; break;
|
||
case "Great": manaGain = levelInfo.manaGainGreat; damageMult = levelInfo.damageMultiplierGreat; break;
|
||
case "Good": manaGain = levelInfo.manaGainGood; damageMult = levelInfo.damageMultiplierGood; break;
|
||
default: manaGain = levelInfo.manaGainOnMiss; break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
switch (judgeResult)
|
||
{
|
||
case "Perfect": manaGain = manaGainPerfect; damageMult = damageMultiplierPerfect; break;
|
||
case "Great": manaGain = manaGainGreat; damageMult = damageMultiplierGreat; break;
|
||
case "Good": manaGain = manaGainGood; damageMult = damageMultiplierGood; break;
|
||
default: manaGain = manaGainOnMiss; break;
|
||
}
|
||
}
|
||
|
||
if (ally != null && manaGain != 0)
|
||
{
|
||
ally.ModifyMana(manaGain, true, false);
|
||
LogVerbose($"[SkillBuilder] Applied mana gain {manaGain} to slot {trackIndex} due to judge {judgeResult}");
|
||
}
|
||
|
||
if (judgeResult == "Miss")
|
||
{
|
||
if (ally != null)
|
||
{
|
||
// 如果所有敌人都已被消灭,Miss 不再扣血
|
||
if (global::EffectSystem.Instance != null && global::EffectSystem.Instance.AreAllEnemiesDead())
|
||
{
|
||
LogVerbose($"[SkillBuilder] All enemies dead -> Skip miss HP loss for slot {trackIndex}");
|
||
return;
|
||
}
|
||
|
||
// Log Miss event to feed (since HP loss will occur)
|
||
string mName = "Unknown";
|
||
if (so != null && !string.IsNullOrEmpty(so.ally_heroName)) mName = so.ally_heroName;
|
||
else if (ally != null) mName = ally.name; // fallback to GameObject name
|
||
SkillTriggerFeedUI.PushMiss(mName);
|
||
|
||
// --- New Miss Damage Formula ---
|
||
// Formula: EnemyAttack * DifficultyLevel * 0.03 * AllyMissHpLossBase * (1 - Resistance)
|
||
// Note: AllyCombatant.ReceiveDamage already applies (1 - Resistance), so we calculate the base here.
|
||
|
||
float enemyAtk = 0f;
|
||
var enemyGO = SceneObjectLookupCache.Find("thisEnemy");
|
||
if (enemyGO != null)
|
||
{
|
||
var ec = enemyGO.GetComponent<EnemyCombatant>();
|
||
if (ec != null) enemyAtk = ec.attack;
|
||
}
|
||
|
||
float diffLevel = 1f;
|
||
if (BeatmapManager.Instance != null && BeatmapManager.Instance.assignedSongData != null)
|
||
{
|
||
int curDiff = BeatmapManager.Instance.assignedDifficulty;
|
||
if (BeatmapManager.Instance.assignedSongData.chartFiles != null)
|
||
{
|
||
var chart = BeatmapManager.Instance.assignedSongData.chartFiles.Find(c => c.difficulty == curDiff);
|
||
if (chart != null) diffLevel = chart.difficultyLEVEL;
|
||
}
|
||
}
|
||
|
||
float fixedMult = 0.03f;
|
||
float allyMissBase = (levelInfo != null) ? levelInfo.missHpLossBase : missHpLossBase;
|
||
|
||
float missBase = enemyAtk * diffLevel * fixedMult * allyMissBase;
|
||
|
||
LogVerbose($"[SkillBuilder] Miss Damage Calc: Atk({enemyAtk}) * DiffLvl({diffLevel}) * Mult({fixedMult}) * AllyBase({allyMissBase}) = {missBase}");
|
||
|
||
// 播放敌人攻击特效,并将伤害逻辑延迟到特效到达时执行
|
||
bool effectStarted = false;
|
||
if (GfxController.Instance != null)
|
||
{
|
||
effectStarted = GfxController.Instance.PlayEnemyAttackOnMiss(trackIndex, missBase, ally);
|
||
}
|
||
|
||
if (!effectStarted)
|
||
{
|
||
// 如果特效启动失败或 GfxController 不存在,则立即扣血作为保底,并触发闪红
|
||
ally.ReceiveDamage(missBase, null);
|
||
if (teamUIController.Instance != null)
|
||
{
|
||
teamUIController.Instance.TriggerAllyHurtFlash(trackIndex);
|
||
}
|
||
LogVerbose($"[SkillBuilder] Gfx failed or missing. Applied immediate miss HP loss for slot {trackIndex}");
|
||
}
|
||
else
|
||
{
|
||
LogVerbose($"[SkillBuilder] Miss occurred for slot {trackIndex}. Damage {missBase} queued via GfxController.");
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (damageMult > 0f)
|
||
{
|
||
int baseAtk = ally != null ? ally.attack : GetAllyBaseAttack(so);
|
||
// damage should scale with (1 - damageResistance) of enemies when applied; here we pass raw amount = baseAtk * damageMult
|
||
float dmg = baseAtk * damageMult;
|
||
DealDamageFromAllyToEnemies(trackIndex, dmg);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void ExecuteSkill(string skillName, EffectType effectType, float amount, Selector selector, GameObject caster, GameObject specificTarget = null, float duration = 0f, float tickInterval = 1f)
|
||
{
|
||
if (global::EffectSystem.Instance == null)
|
||
{
|
||
Debug.LogWarning($"ExecuteSkill failed: EffectSystem.Instance is null");
|
||
return;
|
||
}
|
||
// basic logging
|
||
LogVerbose($"[SkillBuilder] ExecuteSkill: {skillName} type={effectType} amount={amount} selector={selector} caster={(caster?caster.name:"null")} target={(specificTarget?specificTarget.name:"null")} duration={duration}");
|
||
|
||
// additional debug: show when caster is null which will make Selector.Self produce no targets
|
||
if (selector == Selector.Self && caster == null)
|
||
{
|
||
Debug.LogWarning($"[SkillBuilder] ExecuteSkill: selector==Self but caster is null for skill {skillName}. Effect will have no targets.");
|
||
}
|
||
|
||
// call EffectSystem using GameObject API (keeps compatibility with existing EffectSystem)
|
||
global::EffectSystem.Instance.ApplyEffect(selector, effectType, amount, duration, caster, specificTarget, tickInterval);
|
||
}
|
||
|
||
// New overload: execute by slot index (no GameObject required externally)
|
||
public void ExecuteSkillBySlot(string skillName, EffectType effectType, float amount, Selector selector, int casterSlotIndex = -1, int specificTargetSlot = -1, float duration = 0f, float tickInterval = 1f)
|
||
{
|
||
GameObject casterGO = casterSlotIndex >= 0 ? GetAllyObjectBySlot(casterSlotIndex) : null;
|
||
GameObject targetGO = specificTargetSlot >= 0 ? GetAllyObjectBySlot(specificTargetSlot) : null;
|
||
ExecuteSkill(skillName, effectType, amount, selector, casterGO, targetGO, duration, tickInterval);
|
||
}
|
||
|
||
|
||
private void LogVerbose(string message)
|
||
{
|
||
if (GameConfig.verboseLogs) Debug.Log(message);
|
||
}
|
||
|
||
private List<GameObject> GetAlliesCached()
|
||
{
|
||
if (_cachedAlliesFrame == Time.frameCount) return _cachedAllies;
|
||
_cachedAlliesFrame = Time.frameCount;
|
||
_cachedAllies.Clear();
|
||
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
int count = ui.allySlotIds != null ? ui.allySlotIds.Count : 5;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var go = ui.GetAllyObjectBySlot(i);
|
||
if (go != null && !_cachedAllies.Contains(go)) _cachedAllies.Add(go);
|
||
}
|
||
}
|
||
|
||
if (_cachedAllies.Count == 0)
|
||
{
|
||
for (int i = 1; i <= 5; i++)
|
||
{
|
||
var go = SceneObjectLookupCache.Find($"ally_0{i}");
|
||
if (go != null && !_cachedAllies.Contains(go)) _cachedAllies.Add(go);
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
var tagged = GameObject.FindGameObjectsWithTag("Ally");
|
||
foreach (var g in tagged)
|
||
{
|
||
if (g != null && !_cachedAllies.Contains(g)) _cachedAllies.Add(g);
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
return _cachedAllies;
|
||
}
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
// Reused gather buffer for ResolveTargetsLocal. Safe: it is fully consumed into the
|
||
// returned `uniq` list before this method returns, and the gather phase never re-enters
|
||
// ResolveTargetsLocal (skill execution happens later, on the returned list). The specific
|
||
// -target fast path returns a fresh single-element list (it escapes as the result).
|
||
private readonly List<GameObject> _resolveLocalGatherBuffer = new List<GameObject>(8);
|
||
|
||
public List<GameObject> ResolveTargetsLocal(Selector selector, GameObject source = null, GameObject specificTarget = null)
|
||
{
|
||
// If caller provided a specificTarget, honor it as the single target regardless of selector.
|
||
if (specificTarget != null)
|
||
{
|
||
return new List<GameObject> { specificTarget };
|
||
}
|
||
|
||
List<GameObject> list = _resolveLocalGatherBuffer;
|
||
list.Clear();
|
||
switch (selector)
|
||
{
|
||
case Selector.Self:
|
||
if (source != null) list.Add(source);
|
||
break;
|
||
case Selector.AllAllies:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
break;
|
||
case Selector.AllAlliesExceptSelf:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
if (source != null)
|
||
{
|
||
for (int i = list.Count - 1; i >= 0; i--)
|
||
if (list[i] == null || list[i] == source) list.RemoveAt(i);
|
||
}
|
||
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 = SceneObjectLookupCache.Find($"ally_0{idx + 1}"); if (named != null) list.Add(named); }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
int left = slotIndex - 1, right = slotIndex + 1;
|
||
if (left >= 0) { var g = SceneObjectLookupCache.Find($"ally_0{left + 1}"); if (g != null) list.Add(g); }
|
||
if (right <= 4) { var g = SceneObjectLookupCache.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 = SceneObjectLookupCache.Find("thisEnemy"); if (single != null) list.Add(single); } }
|
||
break;
|
||
case Selector.AllEntities:
|
||
foreach (var go in GetAlliesCached())
|
||
{
|
||
if (go != null) list.Add(go);
|
||
}
|
||
try { var taggedE = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in taggedE) if (!list.Contains(g)) list.Add(g); } catch { var single = SceneObjectLookupCache.Find("thisEnemy"); if (single != null && !list.Contains(single)) list.Add(single); }
|
||
break;
|
||
}
|
||
// Build the deduped result (fresh list because it escapes into the caller / skill
|
||
// execution). Inline null-skip + dedupe avoids the RemoveAll closure allocation.
|
||
var uniq = new List<GameObject>(list.Count);
|
||
for (int i = 0; i < list.Count; i++)
|
||
{
|
||
var g = list[i];
|
||
if (g == null) continue;
|
||
if (!uniq.Contains(g)) uniq.Add(g);
|
||
}
|
||
return uniq;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void ModifyTotalScore(int delta)
|
||
{
|
||
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance is null"); return; }
|
||
ScoreManager.Instance.ApplyExternalTotalScoreDelta(delta);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ModifySingleAllyScoreDirect(GameObject allyObject, int delta)
|
||
{
|
||
if (allyObject == null) return;
|
||
var ally = allyObject.GetComponent<AllyCombatant>();
|
||
if (ally != null)
|
||
{
|
||
ally.AddScoreDirect(delta);
|
||
}
|
||
}
|
||
|
||
// Slot-based: modify single ally by slot index (no GameObject required)
|
||
public void ModifySingleAllyScoreDirectBySlot(int slotIndex, int delta)
|
||
{
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) return;
|
||
var ally = go.GetComponent<AllyCombatant>();
|
||
if (ally != null) ally.AddScoreDirect(delta);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ModifyGroupScoreDirect(Selector selector, GameObject caster, int delta)
|
||
{
|
||
var targets = ResolveTargetsLocal(selector, caster);
|
||
foreach (var t in targets)
|
||
{
|
||
var ally = t.GetComponent<AllyCombatant>();
|
||
if (ally != null) ally.AddScoreDirect(delta);
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void ApplyScoreMultiplier(Selector selector, GameObject caster, float multiplier, float duration)
|
||
{
|
||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplier: invalid multiplier"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, multiplier, duration, caster, null);
|
||
}
|
||
|
||
// Slot-based helper: apply score multiplier to a single slot
|
||
public void ApplyScoreMultiplierToSlot(int slotIndex, float multiplier, float duration)
|
||
{
|
||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplierToSlot: invalid multiplier"); return; }
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) { Debug.LogWarning($"ApplyScoreMultiplierToSlot: no ally object for slot {slotIndex}"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.ScoreMultiplier, multiplier, duration, go, null);
|
||
}
|
||
|
||
// Slot-based helper: add flat score to a single slot (immediate)
|
||
public void AddScoreToSlot(int slotIndex, int delta)
|
||
{
|
||
var go = GetAllyObjectBySlot(slotIndex);
|
||
if (go == null) { Debug.LogWarning($"AddScoreToSlot: no ally object for slot {slotIndex}"); return; }
|
||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.AddScore, delta, 0f, go, null);
|
||
}
|
||
|
||
private IEnumerator ApplyBuffToTargetsCoroutine(Buff buff, List<GameObject> targets, GameObject source)
|
||
{
|
||
if (buff == null) yield break;
|
||
foreach (var t in targets)
|
||
{
|
||
if (t == null) continue;
|
||
var comp = t.GetComponent<ICombatant>();
|
||
if (comp != null) comp.ApplyBuff(buff, source);
|
||
else Debug.LogWarning($"ApplyBuffToTargets: target {t.name} has no ICombatant");
|
||
}
|
||
if (buff.duration > 0f)
|
||
yield return GameplayClock.WaitForSeconds(buff.duration);
|
||
else
|
||
yield break;
|
||
|
||
foreach (var t in targets)
|
||
{
|
||
if (t == null) continue;
|
||
var comp = t.GetComponent<ICombatant>();
|
||
if (comp != null) comp.RemoveBuff(buff.buffId);
|
||
}
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void DealSingleEnemyDamage(GameObject caster, GameObject enemyTarget, float amount)
|
||
{
|
||
if (enemyTarget == null) { Debug.LogWarning("DealSingleEnemyDamage: enemyTarget == null"); return; }
|
||
ExecuteSkill("DealSingleEnemyDamage", EffectType.DamageSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||
}
|
||
|
||
// New: heal enemy directly (single)
|
||
public void HealSingleEnemy(GameObject caster, GameObject enemyTarget, float amount)
|
||
{
|
||
if (enemyTarget == null) { Debug.LogWarning("HealSingleEnemy: enemyTarget == null"); return; }
|
||
ExecuteSkill("HealSingleEnemy", EffectType.HealSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||
}
|
||
|
||
// New: heal enemy over time
|
||
public void ApplyHealOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("HealOverTimeEnemy", EffectType.HealOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
// New: damage allies directly (single)
|
||
public void DealSingleAllyDamage(GameObject caster, GameObject allyTarget, float amount)
|
||
{
|
||
if (allyTarget == null) { Debug.LogWarning("DealSingleAllyDamage: allyTarget == null"); return; }
|
||
// This is intended as a single-target ally damage. Use Selector.Self when a specificTarget is provided
|
||
// so target resolution treats the passed allyTarget as the explicit target.
|
||
ExecuteSkill("DealSingleAllyDamage", EffectType.DamageSingleAlly, amount, Selector.Self, caster, allyTarget);
|
||
}
|
||
|
||
// New: damage allies over time
|
||
public void ApplyDamageOverTimeToAllies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("DamageOverTimeAlly", EffectType.DamageOverTimeAlly, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void ApplyDamageOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
if (duration <= 0f) duration = defaultDuration;
|
||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||
ExecuteSkill("DamageOverTime", EffectType.DamageOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void HealSelf(GameObject caster, float amount)
|
||
{
|
||
ExecuteSkill("HealSelf", EffectType.HealSingleSelf, amount, Selector.Self, caster, caster);
|
||
}
|
||
|
||
public void HealSelfOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||
{
|
||
ExecuteSkill("HealSelfOverTime", EffectType.HealOverTimeSelf, totalAmount, Selector.Self, caster, caster, duration, tickInterval);
|
||
}
|
||
|
||
public void HealGroupSingle(GameObject caster, float amount, bool includeSelf = true)
|
||
{
|
||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||
ExecuteSkill("HealGroupSingle", EffectType.HealGroupSingle, amount, sel, caster);
|
||
}
|
||
|
||
public void HealGroupOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f, bool includeSelf = true)
|
||
{
|
||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||
ExecuteSkill("HealGroupOverTime", EffectType.HealGroupOverTime, totalAmount, sel, caster, null, duration, tickInterval);
|
||
}
|
||
|
||
public void IncreaseManaOverTime(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||
{
|
||
ExecuteSkill("IncreaseManaOverTime", EffectType.IncreaseManaOverTime, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
public void ReduceEnemyHealOverTime(GameObject caster, Selector selector, float duration, GameObject specificTarget = null)
|
||
{
|
||
// Default to 50% heal reduction for the duration. Designers can use SkillDefinition.effectType directly
|
||
// with a custom formula/amount if they need other ratios.
|
||
ExecuteSkill("ReduceEnemyHealOverTime", EffectType.ReduceEnemyHealOverTime, 0.5f, selector, caster, specificTarget, duration);
|
||
}
|
||
|
||
public void HealAdjacentAllies(GameObject caster, float amount)
|
||
{
|
||
ExecuteSkill("HealAdjacentAllies", EffectType.HealGroupSingle, amount, Selector.AdjacentAllies, caster);
|
||
}
|
||
|
||
public void IncreaseManaAdjacentAllies(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||
{
|
||
ExecuteSkill("IncreaseManaAdjacentAllies", EffectType.IncreaseManaOverTime, totalAmount, Selector.AdjacentAllies, caster, null, duration, tickInterval);
|
||
}
|
||
|
||
public void Hero_A_Ultimate(GameObject caster, GameObject enemyTarget)
|
||
{
|
||
DealSingleEnemyDamage(caster, enemyTarget, defaultDamage * 2f);
|
||
HealAdjacentAllies(caster, defaultHeal * 0.5f);
|
||
}
|
||
|
||
public void MassHeal(GameObject caster)
|
||
{
|
||
HealGroupOverTime(caster, defaultHeal * 5f, 4f, 1f, true);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public GameObject GetAllyObjectBySlot(int slotIndex)
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null) return ui.GetAllyObjectBySlot(slotIndex);
|
||
var named = SceneObjectLookupCache.Find($"ally_0{slotIndex + 1}");
|
||
return named;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public AllyHero_SO GetAllyHeroSOBySlot(int slotIndex)
|
||
{
|
||
// NOTE: slot index -> hero ID mapping can change between runs/scenes (PlayerPrefs load timing),
|
||
// so the cache must be validated against the current allySlotIds before returning.
|
||
var ui = teamUIController.Instance;
|
||
if (ui == null || ui.allySlotIds == null) return null;
|
||
if (slotIndex < 0 || slotIndex >= ui.allySlotIds.Count) return null;
|
||
int id = ui.allySlotIds[slotIndex];
|
||
if (id <= 0) return null;
|
||
|
||
// slot-based cache (depends on team selection)
|
||
if (_allyHeroSoBySlotCache != null && _allyHeroSoBySlotCache.TryGetValue(slotIndex, out var cached) && cached != null)
|
||
{
|
||
if (cached.ally_heroID == id) return cached;
|
||
// stale entry -> remove so we can rebuild below
|
||
_allyHeroSoBySlotCache.Remove(slotIndex);
|
||
}
|
||
|
||
// Ensure index is built
|
||
if (_allAllyHeroSOs == null || _allAllyHeroSOs.Length == 0) PrewarmAllyHeroSOIndex();
|
||
|
||
AllyHero_SO result = null;
|
||
if (_allyHeroSoById != null && _allyHeroSoById.TryGetValue(id, out var so) && so != null)
|
||
{
|
||
result = so;
|
||
}
|
||
else
|
||
{
|
||
// fallback to previous behavior if index missing
|
||
var all = RuntimeResourcesCache.LoadAllAllyHeroes();
|
||
foreach (var a in all)
|
||
{
|
||
if (a != null && a.ally_heroID == id) { result = a; break; }
|
||
}
|
||
}
|
||
|
||
if (result != null)
|
||
{
|
||
result.LoadEquippedSkillsFromLocal();
|
||
if (_allyHeroSoBySlotCache == null) _allyHeroSoBySlotCache = new Dictionary<int, AllyHero_SO>(8);
|
||
_allyHeroSoBySlotCache[slotIndex] = result;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
private AllyHero_SO.AllyLevelInfo GetEffectiveLevelInfo(AllyHero_SO so)
|
||
{
|
||
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
|
||
// choose the highest requiredEXP that is <= ally_currentEXP
|
||
AllyHero_SO.AllyLevelInfo best = null;
|
||
int currentExp = so.ally_currentEXP;
|
||
foreach (var lvl in so.levelStats)
|
||
{
|
||
if (best == null)
|
||
{
|
||
if (currentExp >= lvl.requiredEXP) best = lvl;
|
||
}
|
||
else
|
||
{
|
||
if (currentExp >= lvl.requiredEXP && lvl.requiredEXP >= best.requiredEXP) best = lvl;
|
||
}
|
||
}
|
||
// if no level matched (all requiredEXP > currentExp), fallback to lowest level (index 0)
|
||
if (best == null) return so.levelStats[0];
|
||
return best;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public int GetAllyBaseAttack(AllyHero_SO so)
|
||
{
|
||
if (so == null) return 0;
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
if (eff != null) return eff.attack;
|
||
if (so.levelStats != null && so.levelStats.Count > 0) return so.levelStats[0].attack;
|
||
return 0;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public float ComputeSkillValue(GameObject caster, int slotIndex, float inputValue, int skillStatic)
|
||
{
|
||
if (inputValue != -1f) return inputValue;
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
var ally = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
int allyAtk = ally != null ? ally.attack : GetAllyBaseAttack(so);
|
||
return skillStatic + allyAtk;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void AllySlotSkill(int slotIndex, string skillId, EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null, float tickInterval = 1f)
|
||
{
|
||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||
if (caster == null)
|
||
{
|
||
Debug.LogWarning($"AllySlotSkill: no caster found for slot {slotIndex}");
|
||
return;
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
int skillStaticDamage = 0;
|
||
switch (skillId)
|
||
{
|
||
case "skill01": skillStaticDamage = 50; break;
|
||
case "skill_heal_small": skillStaticDamage = 30; break;
|
||
// Documentation text normalized.
|
||
default: skillStaticDamage = 0; break;
|
||
}
|
||
|
||
float finalValue = ComputeSkillValue(caster, slotIndex, value, skillStaticDamage);
|
||
|
||
// Documentation text normalized.
|
||
ExecuteSkill($"slot{slotIndex + 1}_{skillId}", effectType, finalValue, selector, caster, specificTarget, duration, tickInterval);
|
||
}
|
||
|
||
// Stat-multiplier buff effects that must stack additively by source rather than compound.
|
||
// Any timed skill of these types is treated as refresh-only so re-triggering the same skill
|
||
// refreshes its single per-source contribution instead of piling on a new one each trigger.
|
||
private static bool IsAdditiveBySourceStatEffect(EffectType effectType)
|
||
{
|
||
switch (effectType)
|
||
{
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static bool IsRefreshOnlyTimedSkill(SkillDefinition def)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
if (s_refreshOnlyTimedSkillIds.Contains(def.skillId)) return true;
|
||
// Timed stat-multiplier buffs default to refresh-only so they add by source, not compound.
|
||
return def.defaultDuration > 0f && IsAdditiveBySourceStatEffect(def.effectType);
|
||
}
|
||
|
||
private static bool IsRefreshOnlyOverTimeSkill(SkillDefinition def)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
return s_refreshOnlyOverTimeSkillIds.Contains(def.skillId);
|
||
}
|
||
|
||
private static string BuildRefreshOnlyEffectKey(SkillDefinition def, GameObject target)
|
||
{
|
||
if (def == null || target == null || string.IsNullOrWhiteSpace(def.skillId)) return null;
|
||
return def.skillId + ":" + target.GetInstanceID();
|
||
}
|
||
|
||
private static bool ShouldSpawnApprForEffect(EffectType effectType)
|
||
{
|
||
switch (effectType)
|
||
{
|
||
case EffectType.DamageOverTimeAlly:
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
case EffectType.RedirectSelfDamageToAdjacent:
|
||
case EffectType.RewriteNonMissToPerfect:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Buff redirection (交给我!/都给你!) should affect any "buff-like" effect that is represented
|
||
// by budeff icons or timed status changes, not only Buff objects.
|
||
private static bool IsRedirectableBudeffEvent(EffectType effectType)
|
||
{
|
||
switch (effectType)
|
||
{
|
||
case EffectType.DamageOverTimeEnemy:
|
||
case EffectType.DamageOverTimeAlly:
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
case EffectType.BuffDuration:
|
||
case EffectType.DebuffDuration:
|
||
case EffectType.IncreaseManaOverTime:
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
case EffectType.RedirectSelfDamageToAdjacent:
|
||
case EffectType.RewriteNonMissToPerfect:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Subset for def.operateDirectly branch where effects are applied inside SkillBuilder switch.
|
||
// Do not include types that fall through to default ExecuteSkill, otherwise redirect would be consumed too early.
|
||
private static bool IsRedirectableDirectBranchEffect(EffectType effectType)
|
||
{
|
||
switch (effectType)
|
||
{
|
||
case EffectType.DamageOverTimeEnemy:
|
||
case EffectType.IncreaseManaOverTime:
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
case EffectType.BuffDuration:
|
||
case EffectType.DebuffDuration:
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
case EffectType.RedirectSelfDamageToAdjacent:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool TryApplyRefreshOnlyTimedEffect(SkillDefinition def, GameObject target, float amount, float duration)
|
||
{
|
||
if (!IsRefreshOnlyTimedSkill(def)) return false;
|
||
if (target == null || duration <= 0f) return false;
|
||
|
||
string key = BuildRefreshOnlyEffectKey(def, target);
|
||
if (string.IsNullOrEmpty(key)) return false;
|
||
|
||
if (_refreshOnlyTimedEffectStates.TryGetValue(key, out var existing))
|
||
{
|
||
RevertRefreshOnlyTimedEffect(target, existing);
|
||
if (_refreshOnlyTimedEffectCoroutines.TryGetValue(key, out var running) && running != null)
|
||
StopCoroutine(running);
|
||
}
|
||
|
||
if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied, key))
|
||
{
|
||
_refreshOnlyTimedEffectStates.Remove(key);
|
||
_refreshOnlyTimedEffectCoroutines.Remove(key);
|
||
return false;
|
||
}
|
||
|
||
var ally = target.GetComponent<AllyCombatant>() ?? target.GetComponentInChildren<AllyCombatant>(true);
|
||
var enemy = target.GetComponent<EnemyCombatant>() ?? target.GetComponentInChildren<EnemyCombatant>(true);
|
||
if (iBudeffPrefabController.Instance != null)
|
||
{
|
||
PlayerBudeffIconType? iconType = null;
|
||
switch (def.effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency: iconType = PlayerBudeffIconType.ot_scoreEfficiency_up; break;
|
||
case EffectType.DecreaseScoreEfficiency: iconType = PlayerBudeffIconType.ot_scoreEfficiency_down; break;
|
||
case EffectType.IncreaseDamageResistance: iconType = PlayerBudeffIconType.ot_defend_up; break;
|
||
case EffectType.DecreaseDamageResistance: iconType = PlayerBudeffIconType.ot_defend_down; break;
|
||
case EffectType.IncreaseAttack: iconType = PlayerBudeffIconType.ot_atk_up; break;
|
||
case EffectType.DecreaseAttack: iconType = PlayerBudeffIconType.ot_atk_down; break;
|
||
case EffectType.IncreaseMaxHP: iconType = PlayerBudeffIconType.ot_maxHP_up; break;
|
||
case EffectType.DecreaseMaxHP: iconType = PlayerBudeffIconType.ot_maxHP_down; break;
|
||
case EffectType.IncreaseMaxMana: iconType = PlayerBudeffIconType.ot_maxMana_up; break;
|
||
case EffectType.DecreaseMaxMana: iconType = PlayerBudeffIconType.ot_maxMana_down; break;
|
||
}
|
||
|
||
if (iconType.HasValue)
|
||
{
|
||
float v = applied.intDelta != 0 ? applied.intDelta : applied.floatDelta;
|
||
if (ally != null)
|
||
{
|
||
applied.budeffSlotIndex = ally.slotIndex;
|
||
applied.budeffIconId = iBudeffPrefabController.Instance.RegisterTimedEffect(ally, iconType.Value, v, duration);
|
||
}
|
||
else if (enemy != null && def.effectType != EffectType.IncreaseScoreEfficiency && def.effectType != EffectType.DecreaseScoreEfficiency)
|
||
{
|
||
applied.budeffEnemyInstanceId = enemy.GetInstanceID();
|
||
applied.budeffIconId = iBudeffPrefabController.Instance.RegisterEnemyTimedEffect(enemy, iconType.Value, v, duration);
|
||
}
|
||
}
|
||
}
|
||
|
||
_refreshOnlyTimedEffectStates[key] = applied;
|
||
_refreshOnlyTimedEffectCoroutines[key] = StartCoroutine(RemoveRefreshOnlyTimedEffectAfterDuration(key, target, duration));
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator RemoveRefreshOnlyTimedEffectAfterDuration(string key, GameObject target, float duration)
|
||
{
|
||
yield return GameplayClock.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, string attackModifierSourceKey = null)
|
||
{
|
||
state = null;
|
||
var ally = target != null ? target.GetComponent<AllyCombatant>() : null;
|
||
var enemy = target != null ? target.GetComponent<EnemyCombatant>() : null;
|
||
if (ally == null && enemy == null) return false;
|
||
|
||
string targetName = ally != null ? ally.allyName : (enemy != null && enemy.sourceData != null ? enemy.sourceData.enemyName : target.name);
|
||
|
||
switch (effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally == null) return false;
|
||
float scoreDelta = effectType == EffectType.IncreaseScoreEfficiency ? amount : -amount;
|
||
float oldScore = ally.scoreEfficiency;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency + scoreDelta);
|
||
CheckLimitAndWarn(targetName, "分数效率", oldScore, ally.scoreEfficiency, scoreDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, floatDelta = scoreDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
float resistDelta = effectType == EffectType.IncreaseDamageResistance ? amount : -amount;
|
||
float oldResist = ally != null ? ally.damageResistance : enemy.damageResistance;
|
||
if (ally != null) ally.damageResistance = ClampDamageResistance(ally.damageResistance + resistDelta);
|
||
else enemy.damageResistance = ClampDamageResistance(enemy.damageResistance + resistDelta);
|
||
float newResist = ally != null ? ally.damageResistance : enemy.damageResistance;
|
||
CheckLimitAndWarn(targetName, "伤害抗性", oldResist, newResist, resistDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, floatDelta = resistDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
int atkDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseAttack) atkDelta = -atkDelta;
|
||
int oldAtk = ally != null ? ally.attack : enemy.attack;
|
||
// Apply as a keyed additive modifier so re-triggering this same skill replaces its
|
||
// contribution (does not compound) and revert removes exactly this source.
|
||
string atkKey = attackModifierSourceKey ?? ("atk:" + (ally != null ? ally.GetInstanceID() : enemy.GetInstanceID()));
|
||
if (ally != null) ally.SetAttackModifier(atkKey, atkDelta);
|
||
else enemy.SetAttackModifier(atkKey, atkDelta);
|
||
int newAtk = ally != null ? ally.attack : enemy.attack;
|
||
CheckLimitAndWarnInt(targetName, "攻击力", oldAtk, newAtk, atkDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = atkDelta, attackModifierKey = atkKey };
|
||
return true;
|
||
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
int hpDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseMaxHP) hpDelta = -hpDelta;
|
||
int oldHP = ally != null ? ally.maxHP : enemy.maxHP;
|
||
if (ally != null)
|
||
{
|
||
ally.SetMaxHP(Mathf.Max(1, ally.maxHP + hpDelta), false);
|
||
if (hpDelta > 0) ally.SetCurrentHP(ally.currentHP + hpDelta, false);
|
||
}
|
||
else
|
||
{
|
||
enemy.SetMaxHP(Mathf.Max(1, enemy.maxHP + hpDelta), false);
|
||
if (hpDelta > 0) enemy.ModifyHP(hpDelta);
|
||
}
|
||
int newHP = ally != null ? ally.maxHP : enemy.maxHP;
|
||
CheckLimitAndWarnInt(targetName, "最大生命值", oldHP, newHP, hpDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = hpDelta };
|
||
return true;
|
||
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
int manaDelta = Mathf.CeilToInt(Mathf.Abs(amount));
|
||
if (effectType == EffectType.DecreaseMaxMana) manaDelta = -manaDelta;
|
||
int oldMana = ally != null ? ally.maxMana : enemy.maxMana;
|
||
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana + manaDelta), false);
|
||
else enemy.SetMaxMana(Mathf.Max(1, enemy.maxMana + manaDelta), false);
|
||
int newMana = ally != null ? ally.maxMana : enemy.maxMana;
|
||
CheckLimitAndWarnInt(targetName, "最大法力值", oldMana, newMana, manaDelta);
|
||
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = manaDelta };
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void CheckLimitAndWarn(string targetName, string attrName, float startValue, float newValue, float attemptedDelta)
|
||
{
|
||
float actualChange = newValue - startValue;
|
||
// If we attempted a change but the actual change differs significantly from attempted, we hit a limit.
|
||
if (Mathf.Abs(attemptedDelta) > 0.001f && Mathf.Abs(actualChange - attemptedDelta) > 0.001f)
|
||
{
|
||
load_skillwarn.Instance?.ShowWarning(targetName, attrName);
|
||
}
|
||
}
|
||
|
||
private void CheckLimitAndWarnInt(string targetName, string attrName, int startValue, int newValue, int attemptedDelta)
|
||
{
|
||
int actualChange = newValue - startValue;
|
||
if (attemptedDelta != 0 && actualChange != attemptedDelta)
|
||
{
|
||
load_skillwarn.Instance?.ShowWarning(targetName, attrName);
|
||
}
|
||
}
|
||
|
||
private static float ClampDamageResistance(float value)
|
||
{
|
||
return Mathf.Min(value, 1f);
|
||
}
|
||
|
||
private void RevertRefreshOnlyTimedEffect(GameObject target, RefreshOnlyTimedState state)
|
||
{
|
||
if (target == null || state == null) return;
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
var enemy = target.GetComponent<EnemyCombatant>();
|
||
if (ally == null && enemy == null) return;
|
||
|
||
switch (state.effectType)
|
||
{
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally != null)
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - state.floatDelta);
|
||
break;
|
||
|
||
case EffectType.IncreaseDamageResistance:
|
||
case EffectType.DecreaseDamageResistance:
|
||
if (ally != null) ally.damageResistance = ClampDamageResistance(ally.damageResistance - state.floatDelta);
|
||
else enemy.damageResistance = ClampDamageResistance(enemy.damageResistance - state.floatDelta);
|
||
break;
|
||
|
||
case EffectType.IncreaseAttack:
|
||
case EffectType.DecreaseAttack:
|
||
if (!string.IsNullOrEmpty(state.attackModifierKey))
|
||
{
|
||
if (ally != null) ally.ClearAttackModifier(state.attackModifierKey);
|
||
else enemy.ClearAttackModifier(state.attackModifierKey);
|
||
}
|
||
else
|
||
{
|
||
// Legacy fallback for states created before keyed modifiers.
|
||
if (ally != null) ally.ModifyAttack(-state.intDelta);
|
||
else enemy.ModifyAttack(-state.intDelta);
|
||
}
|
||
break;
|
||
|
||
case EffectType.IncreaseMaxHP:
|
||
case EffectType.DecreaseMaxHP:
|
||
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP - state.intDelta), false);
|
||
else enemy.SetMaxHP(Mathf.Max(1, enemy.maxHP - state.intDelta), false);
|
||
break;
|
||
|
||
case EffectType.IncreaseMaxMana:
|
||
case EffectType.DecreaseMaxMana:
|
||
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana - state.intDelta), false);
|
||
else enemy.SetMaxMana(Mathf.Max(1, enemy.maxMana - state.intDelta), false);
|
||
break;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(state.budeffIconId))
|
||
{
|
||
if (state.budeffSlotIndex >= 0) iBudeffPrefabController.Instance?.UnregisterTimedEffect(state.budeffSlotIndex, state.budeffIconId);
|
||
else if (state.budeffEnemyInstanceId != 0) iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(state.budeffEnemyInstanceId, state.budeffIconId);
|
||
state.budeffIconId = null;
|
||
state.budeffSlotIndex = -1;
|
||
state.budeffEnemyInstanceId = 0;
|
||
}
|
||
}
|
||
|
||
private bool TryApplyRefreshOnlyOverTimeEffect(SkillDefinition def, GameObject target, float totalAmount, GameObject source)
|
||
{
|
||
if (!IsRefreshOnlyOverTimeSkill(def)) return false;
|
||
if (target == null || def.defaultDuration <= 0f) return false;
|
||
if (def.effectType != EffectType.HealGroupOverTime && def.effectType != EffectType.IncreaseManaOverTime) return false;
|
||
|
||
string key = BuildRefreshOnlyEffectKey(def, target);
|
||
if (string.IsNullOrEmpty(key)) return false;
|
||
|
||
if (_refreshOnlyOverTimeCoroutines.TryGetValue(key, out var running) && running != null)
|
||
StopCoroutine(running);
|
||
|
||
_refreshOnlyOverTimeCoroutines[key] = StartCoroutine(ApplyRefreshOnlyOverTimeEffectCoroutine(key, def, target, totalAmount, source));
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator ApplyRefreshOnlyOverTimeEffectCoroutine(string key, SkillDefinition def, GameObject target, float totalAmount, GameObject source)
|
||
{
|
||
float duration = Mathf.Max(0f, def.defaultDuration);
|
||
float tickInterval = def.GetEffectiveTickInterval();
|
||
if (tickInterval <= 0f) tickInterval = 1f;
|
||
|
||
if (duration <= 0f)
|
||
{
|
||
ApplyRefreshOnlyOverTimeTick(def.effectType, target, totalAmount, source);
|
||
_refreshOnlyOverTimeCoroutines.Remove(key);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (target == null) break;
|
||
if (!ApplyRefreshOnlyOverTimeTick(def.effectType, target, perTick, source)) break;
|
||
|
||
yield return GameplayClock.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);
|
||
return true;
|
||
|
||
case EffectType.IncreaseManaOverTime:
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null) return false;
|
||
ally.ModifyMana(Mathf.CeilToInt(amount), true, true);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// New: invoke a SkillDefinition directly (respects staticValue and SO attack when value == -1)
|
||
public void UseSkillDefinition(SkillDefinition def, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
if (def == null) { Debug.LogWarning("UseSkillDefinition: null def"); return; }
|
||
|
||
int groupId = 0;
|
||
string smallSkillName = null;
|
||
Sprite smallSkillIcon = null;
|
||
AllyHero_SO heroSoForName = null;
|
||
// Resolve group/icon metadata for the ally HUD skill icon queue.
|
||
try
|
||
{
|
||
heroSoForName = GetAllyHeroSOBySlot(slotIndex);
|
||
|
||
// Documentation text normalized.
|
||
if (heroSoForName != null)
|
||
{
|
||
if (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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(def.skillId))
|
||
{
|
||
int[] runtimeGroupIds = heroSoForName.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
var g = heroSoForName.GetSkillGroupByID(gid);
|
||
if (g == null || g.skills == null) continue;
|
||
foreach (var sd in g.skills)
|
||
{
|
||
if (sd == null) continue;
|
||
if (!string.IsNullOrWhiteSpace(sd.skillId) && sd.skillId == def.skillId)
|
||
{
|
||
groupId = g.skillGroupID;
|
||
if (!string.IsNullOrWhiteSpace(g.groupName)) smallSkillName = g.groupName;
|
||
smallSkillIcon = g.groupIcon;
|
||
goto ResolvedGroup;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ResolvedGroup:
|
||
|
||
if (string.IsNullOrWhiteSpace(smallSkillName))
|
||
{
|
||
// Fallback: best-effort readable name.
|
||
if (!string.IsNullOrWhiteSpace(def.summaryInfo)) smallSkillName = def.summaryInfo;
|
||
else if (!string.IsNullOrWhiteSpace(def.displayName)) smallSkillName = def.displayName;
|
||
else smallSkillName = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name;
|
||
}
|
||
|
||
// Dedup: avoid printing multiple times for the same group in a single frame.
|
||
int frame = Time.frameCount;
|
||
int keySlot = slotIndex < 0 ? 0 : slotIndex;
|
||
long key;
|
||
if (groupId != 0)
|
||
key = ((long)keySlot << 32) | (uint)groupId;
|
||
else
|
||
key = ((long)keySlot << 32) | (uint)(smallSkillName.GetHashCode());
|
||
|
||
int lastFrame;
|
||
if (!s_lastSkillFeedFrameByKey.TryGetValue(key, out lastFrame) || lastFrame != frame)
|
||
{
|
||
s_lastSkillFeedFrameByKey[key] = frame;
|
||
|
||
// Push to text feed
|
||
if (heroSoForName != null && !string.IsNullOrEmpty(heroSoForName.ally_heroName))
|
||
{
|
||
SkillTriggerFeedUI.Push(heroSoForName.ally_heroName, smallSkillName);
|
||
}
|
||
|
||
// Ally HUD skill icon feed (newest first, max 3).
|
||
if (smallSkillIcon != null && slotIndex >= 0 && slotIndex < 5)
|
||
{
|
||
try
|
||
{
|
||
var allyGo = GetAllyObjectBySlot(slotIndex);
|
||
var ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null) ally.PushSkillIcon(smallSkillIcon);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
}
|
||
catch { /* never break gameplay because of optional UI */ }
|
||
|
||
// Spawn the track skill popup as soon as the skill is confirmed for this frame.
|
||
// This avoids later target-resolution branches swallowing the popup on early returns.
|
||
SpawnTrackSkillPopup(slotIndex, smallSkillName, smallSkillIcon);
|
||
|
||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||
if (caster == null)
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: no caster GameObject found for slot {slotIndex}. Proceeding with null caster.");
|
||
}
|
||
|
||
// Extra attempts to resolve caster when null: try teamUIController and name pattern fallback
|
||
if (caster == null)
|
||
{
|
||
try
|
||
{
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null)
|
||
{
|
||
var alt = ui.GetAllyObjectBySlot(slotIndex);
|
||
if (alt != null)
|
||
{
|
||
caster = alt;
|
||
LogVerbose($"UseSkillDefinition: resolved caster via teamUIController for slot {slotIndex} -> {caster.name}");
|
||
}
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
if (caster == null)
|
||
{
|
||
var byName = SceneObjectLookupCache.Find($"ally_0{slotIndex + 1}");
|
||
if (byName != null)
|
||
{
|
||
caster = byName;
|
||
LogVerbose($"UseSkillDefinition: resolved caster via name ally_0{slotIndex + 1} -> {caster.name}");
|
||
}
|
||
}
|
||
|
||
// Log useful debug info for diagnosing OnEnemyDead->Self issues
|
||
LogVerbose($"[SkillBuilder] UseSkillDefinition: casting skill {def.skillId} for slot {slotIndex} (caster={(caster?caster.name:"null")}) selector={def.defaultSelector} operateDirectly={def.operateDirectly} inputValue={inputValue} specificTarget={(specificTarget?specificTarget.name:"null")}");
|
||
|
||
if (TryHandleYuetaoLongingSkill(def, slotIndex, caster, heroSoForName, specificTarget))
|
||
{
|
||
return;
|
||
}
|
||
|
||
NotifyGhoulSchoolSkillTriggered(def, slotIndex, caster);
|
||
NotifyForgetfulRhythmSkillTriggered(def, slotIndex);
|
||
|
||
if (TryHandleEquipmentSkill(def, slotIndex, caster, heroSoForName, specificTarget))
|
||
{
|
||
return;
|
||
}
|
||
|
||
float amount = 0f;
|
||
// If caller provided explicit inputValue use it
|
||
if (inputValue != -1f)
|
||
{
|
||
amount = inputValue;
|
||
}
|
||
else
|
||
{
|
||
// Build variables from SO and caster; formula can be a plain number too
|
||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||
var casterAlly = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
var vars = _varsBuffer;
|
||
vars.Clear();
|
||
vars["slot"] = slotIndex;
|
||
|
||
// For attack-buff effects the percentage must be computed off the UNBUFFED base so
|
||
// that "+10% then +20%" sums to +30% of base instead of compounding off already-buffed
|
||
// attack every trigger. Output effects (damage/heal/score) keep using current attack.
|
||
bool attackPercentOffBase = def.effectType == EffectType.IncreaseAttack || def.effectType == EffectType.DecreaseAttack;
|
||
|
||
// Prefer runtime values so buffs/debuffs and max stat changes affect formulas.
|
||
if (casterAlly != null)
|
||
{
|
||
vars["attack"] = attackPercentOffBase ? casterAlly.GetBaseAttack() : casterAlly.attack;
|
||
vars["maxHP"] = casterAlly.maxHP;
|
||
vars["maxMana"] = casterAlly.maxMana;
|
||
vars["damageResistance"] = casterAlly.damageResistance;
|
||
vars["scoreEfficiency"] = casterAlly.scoreEfficiency;
|
||
}
|
||
else if (so != null && so.levelStats != null && so.levelStats.Count > 0)
|
||
{
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
var lvl = eff ?? so.levelStats[0];
|
||
vars["attack"] = lvl.attack;
|
||
vars["maxHP"] = lvl.maxHP;
|
||
vars["maxMana"] = lvl.maxMana;
|
||
vars["damageResistance"] = lvl.damageResistance;
|
||
vars["scoreEfficiency"] = lvl.scoreEfficiency;
|
||
}
|
||
else
|
||
{
|
||
vars["attack"] = GetAllyBaseAttack(so);
|
||
vars["maxHP"] = 0f;
|
||
vars["maxMana"] = 0f;
|
||
vars["damageResistance"] = 0f;
|
||
vars["scoreEfficiency"] = 1f;
|
||
}
|
||
|
||
// Expose level variables (base level from SO).
|
||
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
|
||
{
|
||
var eff = GetEffectiveLevelInfo(so);
|
||
var lvl = eff ?? so.levelStats[0];
|
||
vars["level"] = lvl.levelID;
|
||
vars["levelID"] = lvl.levelID;
|
||
vars["currentLevel"] = lvl.levelID;
|
||
}
|
||
else
|
||
{
|
||
vars["level"] = 0f;
|
||
vars["levelID"] = 0f;
|
||
vars["currentLevel"] = 0f;
|
||
}
|
||
if (so != null) vars["ally_currentEXP"] = so.ally_currentEXP;
|
||
else vars["ally_currentEXP"] = 0f;
|
||
vars["currentMana"] = casterAlly != null ? casterAlly.currentMana : 0f;
|
||
vars["currentScore"] = casterAlly != null ? casterAlly.currentScore : 0f;
|
||
vars["idolScore"] = vars["currentScore"];
|
||
vars["currentHP"] = casterAlly != null ? casterAlly.currentHP : 0f;
|
||
vars["noteBaseScore"] = casterAlly != null
|
||
? (casterAlly.bmm != null ? Mathf.Max(0, casterAlly.bmm.perNoteScore) : Mathf.Max(0, casterAlly.baseTrackScore))
|
||
: 0f;
|
||
|
||
if (string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: skill {def.skillId} has empty formula, defaulting to 0");
|
||
amount = 0f;
|
||
}
|
||
else
|
||
{
|
||
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||
{
|
||
Debug.LogWarning($"UseSkillDefinition: formula evaluation failed for skill {def.skillId}, defaulting to 0");
|
||
amount = 0f;
|
||
}
|
||
else
|
||
{
|
||
amount = fresult;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert formula result (interpreted as per-tick when skill is sustained) to total amount
|
||
// amount now represents the formula result (per-tick for sustained effects, instant for single-instance)
|
||
float amountPerTick = amount;
|
||
float amountTotal = amountPerTick;
|
||
|
||
// Only some effect types treat formula as "per tick". Others (buffs/debuffs/stat changes) should use the
|
||
// raw formula value even if designers leave a non-zero tickInterval by accident.
|
||
bool scaleByTicks =
|
||
!def.IsSingleInstance &&
|
||
(def.effectType == EffectType.DamageOverTimeEnemy ||
|
||
def.effectType == EffectType.DamageOverTimeAlly ||
|
||
def.effectType == EffectType.HealOverTimeEnemy ||
|
||
def.effectType == EffectType.HealOverTimeSelf ||
|
||
def.effectType == EffectType.HealGroupOverTime ||
|
||
def.effectType == EffectType.IncreaseManaOverTime);
|
||
|
||
if (scaleByTicks)
|
||
{
|
||
// compute ticks based on def duration and effective tick interval
|
||
float tickInterval = def.GetEffectiveTickInterval();
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / tickInterval));
|
||
amountTotal = amountPerTick * ticks;
|
||
}
|
||
|
||
// If this skill is intended to be triggered by note hits, treat it as a one-time application
|
||
// (do not multiply by duration ticks). This prevents OnNoteHit triggers from unexpectedly
|
||
// applying sustained totals (e.g. 5s * perTick) when the designer expects a single effect.
|
||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnNoteHit)
|
||
{
|
||
amountTotal = amountPerTick;
|
||
}
|
||
|
||
// Repeat-window override (stateful): if this skill is triggered again within the window,
|
||
// override the computed amount with repeatValue. Used by effects like "300 score, but 500 if retriggered within 5s".
|
||
if (def.repeatWindowSeconds > 0f && def.repeatValue != 0f)
|
||
{
|
||
string sid = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name;
|
||
(int, string) repeatKey = (slotIndex, sid);
|
||
float now = GameplayClock.NowSongTime;
|
||
if (_lastSkillTriggerTime.TryGetValue(repeatKey, out float last) && (now - last) <= def.repeatWindowSeconds)
|
||
{
|
||
amountTotal = def.repeatValue;
|
||
}
|
||
_lastSkillTriggerTime[repeatKey] = now;
|
||
}
|
||
|
||
if (GameplaySkillLogger.Enabled)
|
||
{
|
||
try
|
||
{
|
||
string casterDisplayName = ResolveCasterDisplayNameForLog(slotIndex, heroSoForName, caster);
|
||
string skillDisplayNameForLog = string.IsNullOrWhiteSpace(smallSkillName)
|
||
? (!string.IsNullOrWhiteSpace(def.displayName) ? def.displayName : (!string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name))
|
||
: smallSkillName;
|
||
string effectSummary = BuildSkillEffectSummaryForLog(def, amountPerTick, amountTotal);
|
||
string targetSummary = BuildSkillTargetSummaryForLog(def.defaultSelector, specificTarget);
|
||
GameplaySkillLogger.RecordSkillRelease(
|
||
casterDisplayName,
|
||
skillDisplayNameForLog,
|
||
def.effectType.ToString(),
|
||
effectSummary,
|
||
targetSummary);
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
LogVerbose("[SkillBuilder] GameplaySkillLogger failed: " + ex.Message);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
GameObject target = t;
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally != null && IsRedirectableDirectBranchEffect(def.effectType))
|
||
{
|
||
if (ally.TryRedirectIncomingGenericBuff(out var redirectedAlly) && redirectedAlly != null)
|
||
{
|
||
target = redirectedAlly.gameObject;
|
||
ally = redirectedAlly;
|
||
}
|
||
}
|
||
var ic = target.GetComponent<ICombatant>();
|
||
|
||
if (GameConfig.skillDebugMode)
|
||
{
|
||
Debug.Log($"<color=#00FFFF>[SkillDebug]</color> <color=#FFD700>ID: {def.skillId}</color> | <color=#00FF00>Trigger: {def.triggerCondition}</color> | <color=#FFA500>Target: {target.name}</color> | <color=#EE82EE>Effect: {def.effectType}</color> | <color=#FF4500>Value: {amountTotal:F2}</color>");
|
||
}
|
||
|
||
if (ally != null && ShouldSpawnApprForEffect(def.effectType))
|
||
{
|
||
var apprSprite = iBudeffPrefabController.Instance != null ? iBudeffPrefabController.Instance.GetSpriteForEffect(def.effectType) : null;
|
||
if (apprSprite != null) iBudeffPrefabController.Instance?.SpawnApprForSlot(ally.slotIndex, apprSprite);
|
||
}
|
||
|
||
switch (def.effectType)
|
||
{
|
||
case EffectType.DamageSingleEnemy:
|
||
// treat as damage to an enemy; if target has AllyCombatant, apply negative HP
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountTotal), true);
|
||
else if (ic != null) ic.ReceiveDamage(amountTotal, caster);
|
||
break;
|
||
case EffectType.DamageOverTimeEnemy:
|
||
// sustained: start coroutine to apply per-tick damage rather than instant total
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountPerTick), true);
|
||
else if (ic != null) ic.ReceiveDamage(amountPerTick, caster);
|
||
}
|
||
else
|
||
{
|
||
string iconId = null;
|
||
int allySlotIndex = -1;
|
||
int enemyInstanceId = 0;
|
||
var enemyDot = target.GetComponent<EnemyCombatant>() ?? target.GetComponentInChildren<EnemyCombatant>(true);
|
||
if (enemyDot != null)
|
||
{
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / def.GetEffectiveTickInterval()));
|
||
float perTick = amountTotal / ticks;
|
||
iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemyDot, PlayerBudeffIconType.ot_bleeding, perTick, def.defaultDuration);
|
||
enemyInstanceId = enemyDot.GetInstanceID();
|
||
}
|
||
else if (ally != null)
|
||
{
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / def.GetEffectiveTickInterval()));
|
||
float perTick = amountTotal / ticks;
|
||
iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_bleeding, perTick, def.defaultDuration);
|
||
allySlotIndex = ally.slotIndex;
|
||
}
|
||
StartCoroutine(ApplyDamageOverTimeDirect(target, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster, iconId, allySlotIndex, enemyInstanceId));
|
||
}
|
||
break;
|
||
case EffectType.HealSingleSelf:
|
||
case EffectType.HealGroupSingle:
|
||
if (ally != null) ally.ReceiveHeal(amountTotal, caster);
|
||
else if (ic != null) ic.ReceiveHeal(amountTotal, caster);
|
||
break;
|
||
case EffectType.HealOverTimeSelf:
|
||
case EffectType.HealGroupOverTime:
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(amountPerTick, caster);
|
||
else ic?.ReceiveHeal(amountPerTick, caster);
|
||
}
|
||
else
|
||
{
|
||
StartCoroutine(ApplyHealOverTimeDirect(target, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster));
|
||
}
|
||
break;
|
||
case EffectType.IncreaseManaOverTime:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
// single instance - amountPerTick equals immediate amount
|
||
ally.ModifyMana(Mathf.CeilToInt(amountPerTick), true, true);
|
||
}
|
||
else
|
||
{
|
||
// for sustained, pass total amount (amountTotal) to coroutine which expects total
|
||
StartCoroutine(ApplyIncreaseManaOverTimeDirect(ally, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval()));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
if (ic != null)
|
||
{
|
||
float reduction = Mathf.Clamp01(amountPerTick);
|
||
var deb = new Buff
|
||
{
|
||
buffId = System.Guid.NewGuid().ToString(),
|
||
duration = def.defaultDuration,
|
||
description = "ReduceHeal",
|
||
healReceivedMultiplier = 1f - reduction
|
||
};
|
||
ic.ApplyBuff(deb, caster);
|
||
StartCoroutine(RemoveBuffAfterDuration(target, deb.buffId, deb.duration));
|
||
}
|
||
break;
|
||
case EffectType.BuffDuration:
|
||
case EffectType.DebuffDuration:
|
||
if (ic != null)
|
||
{
|
||
// Generic timed buff/debuff: use amount as score multiplier (1.5 = +50%, 0.5 = -50%).
|
||
float mult = amountPerTick;
|
||
if (mult <= 0f) mult = 1f;
|
||
var b = new Buff
|
||
{
|
||
buffId = System.Guid.NewGuid().ToString(),
|
||
duration = def.defaultDuration,
|
||
scoreMultiplier = mult
|
||
};
|
||
ic.ApplyBuff(b, caster);
|
||
StartCoroutine(RemoveBuffAfterDuration(target, b.buffId, b.duration));
|
||
}
|
||
break;
|
||
// Documentation text normalized.
|
||
case EffectType.IncreaseMaxHP:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxHP(ally.maxHP + d, false);
|
||
if (d > 0) ally.SetCurrentHP(ally.currentHP + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxHP_up, d, 0f);
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy.SetMaxHP(enemy.maxHP + d, false);
|
||
if (d > 0) enemy.ModifyHP(d);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy, PlayerBudeffIconType.ot_maxHP_up, d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.DecreaseMaxHP:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxHP(Mathf.Max(1, ally.maxHP - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxHP_down, -d, 0f);
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy2))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy2.SetMaxHP(Mathf.Max(1, enemy2.maxHP - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy2, PlayerBudeffIconType.ot_maxHP_down, -d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.IncreaseMaxMana:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxMana(ally.maxMana + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxMana_up, d, 0f);
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy3))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy3.SetMaxMana(enemy3.maxMana + d, false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy3, PlayerBudeffIconType.ot_maxMana_up, d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.DecreaseMaxMana:
|
||
if (ally != null)
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
ally.SetMaxMana(Mathf.Max(1, ally.maxMana - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_maxMana_down, -d, 0f);
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy4))
|
||
{
|
||
int d = Mathf.CeilToInt(amountTotal);
|
||
enemy4.SetMaxMana(Mathf.Max(1, enemy4.maxMana - d), false);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy4, PlayerBudeffIconType.ot_maxMana_down, -d, 0f);
|
||
}
|
||
break;
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.scoreEfficiency += amountTotal;
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_up, amountTotal, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_up, amountTotal, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, amountTotal, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_down, -amountTotal, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_down, -amountTotal, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryScoreEfficiencyChangeDirect(ally, -amountTotal, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.IncreaseAttack:
|
||
{
|
||
int delta = Mathf.CeilToInt(amountTotal);
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_up, delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_up, delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, delta, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy5))
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
enemy5.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy5, PlayerBudeffIconType.ot_atk_up, delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy5, PlayerBudeffIconType.ot_atk_up, delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy5, delta, def.defaultDuration, iconId, enemy5.GetInstanceID()));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.DecreaseAttack:
|
||
{
|
||
int delta = Mathf.CeilToInt(amountTotal);
|
||
if (ally != null)
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
ally.ModifyAttack(-delta);
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_down, -delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_atk_down, -delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(ally, -delta, def.defaultDuration, iconId));
|
||
}
|
||
}
|
||
else if (target.TryGetComponent<EnemyCombatant>(out var enemy6))
|
||
{
|
||
if (def.defaultDuration <= 0f)
|
||
{
|
||
enemy6.ModifyAttack(-delta);
|
||
iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy6, PlayerBudeffIconType.ot_atk_down, -delta, 0f);
|
||
}
|
||
else if (!TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration))
|
||
{
|
||
var iconId = iBudeffPrefabController.Instance?.RegisterEnemyTimedEffect(enemy6, PlayerBudeffIconType.ot_atk_down, -delta, def.defaultDuration);
|
||
StartCoroutine(ApplyTemporaryAttackChangeDirect(enemy6, -delta, def.defaultDuration, iconId, enemy6.GetInstanceID()));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
if (ally != null) AllyCombatant.ActivateNextDamageRedirect(ally, def.defaultDuration);
|
||
break;
|
||
default:
|
||
// non-direct path handled below, but keep compatibility
|
||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, amountTotal, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (GameConfig.skillDebugMode)
|
||
{
|
||
Debug.Log($"<color=#00FFFF>[SkillDebug]</color> <color=#FFD700>ID: {def.skillId}</color> | <color=#00FF00>Trigger: {def.triggerCondition}</color> | <color=#FFA500>Target: (Resolving via EffectSystem)</color> | <color=#EE82EE>Effect: {def.effectType}</color> | <color=#FF4500>Value: {amountTotal:F2}</color>");
|
||
}
|
||
|
||
if (ShouldSpawnApprForEffect(def.effectType))
|
||
{
|
||
var apprSprite = iBudeffPrefabController.Instance != null ? iBudeffPrefabController.Instance.GetSpriteForEffect(def.effectType) : null;
|
||
if (apprSprite != null)
|
||
{
|
||
var apprTargets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||
if (apprTargets != null)
|
||
{
|
||
foreach (var t in apprTargets)
|
||
{
|
||
if (t == null) continue;
|
||
var ally = t.GetComponent<AllyCombatant>() ?? t.GetComponentInChildren<AllyCombatant>(true);
|
||
if (ally != null) iBudeffPrefabController.Instance?.SpawnApprForSlot(ally.slotIndex, apprSprite);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (def.defaultDuration > 0f)
|
||
{
|
||
var resolvedTargets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||
bool appliedRefreshOnly = false;
|
||
bool mayHandleLocally = IsRefreshOnlyTimedSkill(def) || IsRefreshOnlyOverTimeSkill(def);
|
||
if (resolvedTargets != null)
|
||
{
|
||
foreach (var t in resolvedTargets)
|
||
{
|
||
if (t == null) continue;
|
||
var target = t;
|
||
if (mayHandleLocally)
|
||
{
|
||
var ally = target.GetComponent<AllyCombatant>() ?? target.GetComponentInChildren<AllyCombatant>(true);
|
||
if (ally != null && IsRedirectableBudeffEvent(def.effectType))
|
||
{
|
||
if (ally.TryRedirectIncomingGenericBuff(out var redirectedAlly) && redirectedAlly != null)
|
||
{
|
||
target = redirectedAlly.gameObject;
|
||
}
|
||
}
|
||
}
|
||
|
||
bool handled = TryApplyRefreshOnlyTimedEffect(def, target, amountTotal, def.defaultDuration);
|
||
if (!handled) handled = TryApplyRefreshOnlyOverTimeEffect(def, target, 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);
|
||
}
|
||
}
|
||
|
||
private bool TryHandleYuetaoLongingSkill(SkillDefinition def, int slotIndex, GameObject caster, AllyHero_SO heroSoForName, GameObject specificTarget)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
if (def.skillId != SkillIdYuetaoLongingDamage && def.skillId != SkillIdYuetaoLongingCost) return false;
|
||
|
||
var casterAlly = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
if (casterAlly == null)
|
||
{
|
||
Debug.LogWarning($"[SkillBuilder] Yuetao Longing custom handler skipped: caster ally missing for slot {slotIndex}");
|
||
return false;
|
||
}
|
||
|
||
int consumedHp = 0;
|
||
int nowFrame = Time.frameCount;
|
||
if (_manaFullLongingStateBySlot.TryGetValue(slotIndex, out var state) && state.frame == nowFrame)
|
||
{
|
||
consumedHp = state.consumedHp;
|
||
}
|
||
else
|
||
{
|
||
consumedHp = ConsumeYuetaoLongingHpCost(casterAlly);
|
||
_manaFullLongingStateBySlot[slotIndex] = new ManaFullLongingState { frame = nowFrame, consumedHp = consumedHp };
|
||
}
|
||
|
||
string casterDisplayName = ResolveCasterDisplayNameForLog(slotIndex, heroSoForName, caster);
|
||
string skillDisplayName = !string.IsNullOrWhiteSpace(def.displayName) ? def.displayName : (!string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name);
|
||
string targetSummary = BuildSkillTargetSummaryForLog(def.defaultSelector, specificTarget);
|
||
|
||
if (def.skillId == SkillIdYuetaoLongingCost)
|
||
{
|
||
try
|
||
{
|
||
string effectSummary = "生命值" + FormatSignedIntForLog(-Mathf.Abs(consumedHp)) + " (上限3%, 保底>20%)";
|
||
GameplaySkillLogger.RecordSkillRelease(casterDisplayName, skillDisplayName, def.effectType.ToString(), effectSummary, targetSummary);
|
||
}
|
||
catch { }
|
||
return true;
|
||
}
|
||
|
||
float attackBonus = Mathf.Max(0f, casterAlly.attack) * 0.1f;
|
||
float damage = consumedHp + attackBonus;
|
||
if (damage > 0f)
|
||
{
|
||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, damage, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||
}
|
||
|
||
try
|
||
{
|
||
string effectSummary = "重击伤害=" + damage.ToString("0.###") + " (耗血" + consumedHp + "+攻击10%:" + attackBonus.ToString("0.###") + ")";
|
||
GameplaySkillLogger.RecordSkillRelease(casterDisplayName, skillDisplayName, def.effectType.ToString(), effectSummary, targetSummary);
|
||
}
|
||
catch { }
|
||
return true;
|
||
}
|
||
|
||
private static int ConsumeYuetaoLongingHpCost(AllyCombatant ally)
|
||
{
|
||
if (ally == null || ally.maxHP <= 0 || ally.currentHP <= 0) return 0;
|
||
|
||
int rawCost = Mathf.Max(0, Mathf.CeilToInt(ally.maxHP * 0.03f));
|
||
if (rawCost <= 0) return 0;
|
||
|
||
// "cannot drop to 20% max HP or below" => keep HP strictly greater than 20%.
|
||
int minRemainExclusive = Mathf.FloorToInt(ally.maxHP * 0.2f) + 1;
|
||
int maxAllowedCost = Mathf.Max(0, ally.currentHP - minRemainExclusive);
|
||
int finalCost = Mathf.Clamp(rawCost, 0, maxAllowedCost);
|
||
if (finalCost <= 0) return 0;
|
||
|
||
ally.ModifyHP(-finalCost, true);
|
||
return finalCost;
|
||
}
|
||
|
||
private bool TryHandleEquipmentSkill(SkillDefinition def, int slotIndex, GameObject caster, AllyHero_SO heroSoForName, GameObject specificTarget)
|
||
{
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false;
|
||
|
||
var casterAlly = caster != null ? caster.GetComponent<AllyCombatant>() : null;
|
||
if (casterAlly == null) return false;
|
||
|
||
switch (def.skillId)
|
||
{
|
||
case EquipSkillLostMaster:
|
||
return HandleEquipSkillLostMaster(casterAlly);
|
||
case EquipSkillDuelStart:
|
||
return HandleEquipSkillDuelStart(slotIndex, casterAlly);
|
||
case EquipSkillDuelDouble:
|
||
return HandleEquipSkillDuelDouble(slotIndex);
|
||
case EquipSkillDuelDisable:
|
||
return HandleEquipSkillDuelDisable(slotIndex);
|
||
case EquipSkillGhoulSchool:
|
||
return HandleEquipSkillGhoulSchool(slotIndex);
|
||
case EquipSkillFifthManaRestore:
|
||
return HandleEquipSkillFifthManaRestore(slotIndex, casterAlly);
|
||
case EquipSkillMaxHpToAttack:
|
||
return HandleEquipSkillMaxHpToAttack(casterAlly);
|
||
case EquipSkillSelfDamagedGrowHp:
|
||
return HandleEquipSkillSelfDamagedGrowHp(casterAlly);
|
||
case EquipSkillAdjacentDamagedAddScore:
|
||
return HandleEquipSkillAdjacentDamagedAddScore(casterAlly);
|
||
case EquipSkillStartSetMana:
|
||
return HandleEquipSkillStartSetMana(casterAlly);
|
||
case EquipSkillKillRefillMana:
|
||
return HandleEquipSkillKillRefillMana(casterAlly);
|
||
case EquipSkillManaSpendReduceCap:
|
||
return HandleEquipSkillManaSpendReduceCap(casterAlly);
|
||
case EquipSkillVacantPerfectMana:
|
||
return HandleEquipSkillVacantPerfectMana(casterAlly);
|
||
case EquipSkillVacantMissHeal:
|
||
return HandleEquipSkillVacantMissHeal(casterAlly);
|
||
case EquipSkillThousandthTracker:
|
||
return HandleEquipSkillThousandthTracker(slotIndex);
|
||
case EquipSkillForgetfulRhythm:
|
||
return HandleEquipSkillForgetfulRhythm(slotIndex);
|
||
case EquipSkillTalentScout:
|
||
return HandleEquipSkillTalentScout(slotIndex);
|
||
case EquipSkillOutOfLineManaScore:
|
||
return HandleEquipSkillOutOfLineManaScore(slotIndex, casterAlly);
|
||
case EquipSkillOutOfLineEnemyDead:
|
||
return HandleEquipSkillOutOfLineEnemyDead(slotIndex);
|
||
case EquipSkillStormStart:
|
||
return HandleEquipSkillStormStart(slotIndex, casterAlly);
|
||
case EquipSkillMissionStart:
|
||
return HandleEquipSkillMissionStart(casterAlly);
|
||
case EquipSkillMissionFullMana:
|
||
return HandleEquipSkillMissionFullMana(casterAlly);
|
||
case EquipSkillAllForYouMana:
|
||
return HandleEquipSkillAllForYouMana(casterAlly);
|
||
case EquipSkillAllForYouKill:
|
||
return HandleEquipSkillAllForYouKill(casterAlly);
|
||
case EquipSkillFateRelianceStart:
|
||
return HandleEquipSkillFateRelianceStart(slotIndex);
|
||
case EquipSkillNewIdeaKill:
|
||
return HandleEquipSkillNewIdeaStack(slotIndex, 3f);
|
||
case EquipSkillNewIdeaClear:
|
||
return HandleEquipSkillNewIdeaStack(slotIndex, 8f);
|
||
case EquipSkillNamelessLaneKill:
|
||
return HandleEquipSkillNamelessLaneKill(slotIndex);
|
||
case EquipSkillSocialMaskStart:
|
||
return HandleEquipSkillSocialMaskStart(slotIndex, casterAlly);
|
||
case EquipSkillSocialMaskKill:
|
||
return HandleEquipSkillSocialMaskKill(slotIndex, casterAlly);
|
||
case EquipSkillSocialMaskClear:
|
||
return HandleEquipSkillSocialMaskClear(slotIndex, casterAlly);
|
||
case EquipSkillQuietTurn:
|
||
return HandleEquipSkillQuietTurn(casterAlly);
|
||
case EquipSkillBlock:
|
||
return HandleEquipSkillBlock(casterAlly);
|
||
case EquipSkillFutureStart:
|
||
return HandleEquipSkillFutureStart(slotIndex);
|
||
case EquipSkillFutureHeal:
|
||
return HandleEquipSkillFutureHeal(slotIndex, casterAlly);
|
||
case EquipSkillFutureClear:
|
||
return HandleEquipSkillFutureClear(slotIndex, casterAlly);
|
||
case EquipSkillQuietTurnHealAdjacent:
|
||
return HandleEquipSkillQuietTurnHealAdjacent(casterAlly);
|
||
case EquipSkillBlockStart:
|
||
return HandleEquipSkillBlockStart(casterAlly);
|
||
case IllusionSkillBole14StartMaxHp:
|
||
return HandleIllusionSkillBole14StartMaxHp(casterAlly);
|
||
case IllusionSkillBole6SpendFullMaxHp:
|
||
return HandleIllusionSkillBole6SpendFullMaxHp(casterAlly);
|
||
case IllusionSkillScarborough28StartAttack:
|
||
return HandleIllusionSkillScarborough28StartAttack(casterAlly);
|
||
case IllusionSkillScarborough33SpendFullScore:
|
||
return HandleIllusionSkillScarborough33SpendFullScore(casterAlly);
|
||
case IllusionSkillBole7KillHeal:
|
||
return HandleIllusionSkillBole7KillHeal(casterAlly);
|
||
case IllusionSkillCraft9SpendFullMana:
|
||
return HandleIllusionSkillCraft9SpendFullMana(casterAlly);
|
||
case IllusionSkillTeraExtraSlot:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static readonly string[] s_trackSkillPopupColors = { "red", "green", "yellow", "purple", "blue" };
|
||
|
||
private void SpawnTrackSkillPopup(int slotIndex, string skillName, Sprite skillIcon)
|
||
{
|
||
if (slotIndex < 0 || slotIndex >= s_trackSkillPopupColors.Length)
|
||
return;
|
||
|
||
var fx = Animation_GenerateJudgementSituationPrefab.Instance
|
||
?? SceneObjectLookupCache.FindAny<Animation_GenerateJudgementSituationPrefab>();
|
||
if (fx == null)
|
||
return;
|
||
|
||
fx.SpawnTrackSkillPrefab(s_trackSkillPopupColors[slotIndex], skillIcon, skillName ?? string.Empty);
|
||
}
|
||
|
||
private bool HandleEquipSkillGhoulSchool(int slotIndex)
|
||
{
|
||
_equipGhoulSchoolActiveSlots.Add(slotIndex);
|
||
if (!_equipGhoulSchoolSeenSkillIdsBySlot.ContainsKey(slotIndex))
|
||
{
|
||
_equipGhoulSchoolSeenSkillIdsBySlot[slotIndex] = new HashSet<string>();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private void NotifyGhoulSchoolSkillTriggered(SkillDefinition def, int slotIndex, GameObject caster)
|
||
{
|
||
if (def == null || slotIndex < 0) return;
|
||
if (def.skillId == EquipSkillGhoulSchool) return;
|
||
if (!_equipGhoulSchoolActiveSlots.Contains(slotIndex)) return;
|
||
if (string.IsNullOrWhiteSpace(def.skillId)) return;
|
||
|
||
if (!_equipGhoulSchoolSeenSkillIdsBySlot.TryGetValue(slotIndex, out HashSet<string> seen))
|
||
{
|
||
seen = new HashSet<string>();
|
||
_equipGhoulSchoolSeenSkillIdsBySlot[slotIndex] = seen;
|
||
}
|
||
|
||
if (!seen.Add(def.skillId))
|
||
{
|
||
return;
|
||
}
|
||
|
||
AllyCombatant ally = caster != null ? caster.GetComponent<AllyCombatant>() : GetAllyObjectBySlot(slotIndex)?.GetComponent<AllyCombatant>();
|
||
if (ally == null || ally.IsDead) return;
|
||
|
||
ally.scoreEfficiency += 0.01f;
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_up, 0.01f, 0f);
|
||
}
|
||
|
||
private void NotifyForgetfulRhythmSkillTriggered(SkillDefinition def, int slotIndex)
|
||
{
|
||
if (def == null || slotIndex < 0) return;
|
||
if (def.skillId == EquipSkillForgetfulRhythm) return;
|
||
if (!_equipForgetfulRhythmActiveSlots.Contains(slotIndex)) return;
|
||
if (string.IsNullOrWhiteSpace(def.skillId)) return;
|
||
|
||
if (!_equipForgetfulRhythmSeenSkillIdsBySlot.TryGetValue(slotIndex, out HashSet<string> seen))
|
||
{
|
||
seen = new HashSet<string>();
|
||
_equipForgetfulRhythmSeenSkillIdsBySlot[slotIndex] = seen;
|
||
}
|
||
|
||
if (!seen.Add(def.skillId))
|
||
{
|
||
return;
|
||
}
|
||
|
||
int stacks = 0;
|
||
_equipForgetfulRhythmStacksBySlot.TryGetValue(slotIndex, out stacks);
|
||
_equipForgetfulRhythmStacksBySlot[slotIndex] = stacks + 1;
|
||
}
|
||
|
||
private void RefreshEquipThousandthBonus(int slotIndex)
|
||
{
|
||
if (!_equipThousandthActiveSlots.Contains(slotIndex)) return;
|
||
|
||
var allyGo = GetAllyObjectBySlot(slotIndex);
|
||
var ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally == null || ally.IsDead) return;
|
||
|
||
int currentCombo = teamUIController.Instance != null ? teamUIController.Instance.CurrentCombo : 0;
|
||
int perfectCount = ScoreManager.Instance != null ? Mathf.Max(0, ScoreManager.Instance.countPerfect) : 0;
|
||
|
||
float newBonus = 0f;
|
||
if (currentCombo >= 300)
|
||
{
|
||
newBonus += 0.02f;
|
||
}
|
||
|
||
newBonus += Mathf.FloorToInt(perfectCount / 100f) * 0.005f;
|
||
|
||
float previousBonus = 0f;
|
||
_equipThousandthAppliedBonusBySlot.TryGetValue(slotIndex, out previousBonus);
|
||
float delta = newBonus - previousBonus;
|
||
if (Mathf.Abs(delta) > 0.0001f)
|
||
{
|
||
ally.scoreEfficiency += delta;
|
||
_equipThousandthAppliedBonusBySlot[slotIndex] = newBonus;
|
||
}
|
||
}
|
||
|
||
private void ApplyForgetfulRhythmPerfectBonus(int slotIndex)
|
||
{
|
||
if (!_equipForgetfulRhythmActiveSlots.Contains(slotIndex)) return;
|
||
|
||
int stacks = 0;
|
||
_equipForgetfulRhythmStacksBySlot.TryGetValue(slotIndex, out stacks);
|
||
if (stacks <= 0) return;
|
||
|
||
ScoreManager scoreManager = ScoreManager.Instance;
|
||
if (scoreManager != null)
|
||
{
|
||
scoreManager.countPerfect += stacks;
|
||
if (slotIndex >= 0 && slotIndex < scoreManager.trackPerfectCounts.Length)
|
||
{
|
||
scoreManager.trackPerfectCounts[slotIndex] += stacks;
|
||
}
|
||
}
|
||
|
||
if (teamUIController.Instance != null)
|
||
{
|
||
for (int i = 0; i < stacks; i++)
|
||
{
|
||
teamUIController.Instance.OnJudgeResult("Perfect");
|
||
}
|
||
}
|
||
}
|
||
|
||
private void HandleEquipNoteJudgePassives(int slotIndex, string judgeResult)
|
||
{
|
||
if (slotIndex < 0 || string.IsNullOrWhiteSpace(judgeResult)) return;
|
||
|
||
if (judgeResult == "Perfect")
|
||
{
|
||
ApplyForgetfulRhythmPerfectBonus(slotIndex);
|
||
}
|
||
|
||
RefreshEquipThousandthBonus(slotIndex);
|
||
}
|
||
|
||
public void NotifyAllyAttackDealt(int slotIndex)
|
||
{
|
||
if (slotIndex < 0) return;
|
||
if (!_equipStormStacksBySlot.TryGetValue(slotIndex, out int stacks) || stacks <= 0) return;
|
||
if (_equipStormProcessingSlots.Contains(slotIndex)) return;
|
||
|
||
GameObject allyGo = GetAllyObjectBySlot(slotIndex);
|
||
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally == null || ally.IsDead || ally.attack <= 0) return;
|
||
if (EffectSystem.Instance == null) return;
|
||
|
||
float perHitDamage = Mathf.Max(1f, ally.attack / (float)stacks);
|
||
_equipStormProcessingSlots.Add(slotIndex);
|
||
try
|
||
{
|
||
for (int i = 0; i < stacks; i++)
|
||
{
|
||
EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, perHitDamage, 0f, ally.gameObject, null);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_equipStormProcessingSlots.Remove(slotIndex);
|
||
}
|
||
}
|
||
|
||
public void NotifyBuffApplied(int receiverSlotIndex)
|
||
{
|
||
if (receiverSlotIndex < 0) return;
|
||
|
||
GameObject receiverGo = GetAllyObjectBySlot(receiverSlotIndex);
|
||
AllyCombatant receiver = receiverGo != null ? receiverGo.GetComponent<AllyCombatant>() : null;
|
||
if (receiver == null || receiver.IsDead) return;
|
||
|
||
if (_equipFateRelianceActiveSlots.Contains(receiverSlotIndex))
|
||
{
|
||
int selfGain = Mathf.CeilToInt(receiver.maxMana * 0.10f);
|
||
if (selfGain > 0)
|
||
{
|
||
receiver.ModifyMana(selfGain, true, true);
|
||
}
|
||
}
|
||
|
||
if (teamUIController.Instance == null) return;
|
||
int[] adjacent = teamUIController.Instance.GetAdjacentAllyIndices(receiverSlotIndex);
|
||
if (adjacent == null) return;
|
||
|
||
for (int i = 0; i < adjacent.Length; i++)
|
||
{
|
||
int slot = adjacent[i];
|
||
if (!_equipFateRelianceActiveSlots.Contains(slot)) continue;
|
||
GameObject allyGo = GetAllyObjectBySlot(slot);
|
||
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally == null || ally.IsDead) continue;
|
||
|
||
int gain = Mathf.CeilToInt(ally.maxMana * 0.15f);
|
||
if (gain > 0)
|
||
{
|
||
ally.ModifyMana(gain, true, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
public bool TryPreventLethalDamage(AllyCombatant ally, int incomingDamage)
|
||
{
|
||
if (ally == null || ally.IsDead) return false;
|
||
if (incomingDamage <= 0) return false;
|
||
if (ally.currentHP - incomingDamage > 0) return false;
|
||
|
||
int slotIndex = ally.slotIndex;
|
||
|
||
if (_equipSocialMaskActiveSlots.Contains(slotIndex))
|
||
{
|
||
DeactivateSocialMask(slotIndex, ally);
|
||
return true;
|
||
}
|
||
|
||
if (_equipFutureActiveSlots.Contains(slotIndex))
|
||
{
|
||
_equipFutureActiveSlots.Remove(slotIndex);
|
||
int restoreHp = Mathf.CeilToInt(ally.maxHP * 0.30f);
|
||
ally.SetCurrentHP(Mathf.Max(1, restoreHp), true);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private bool HandleEquipSkillLostMaster(AllyCombatant ally)
|
||
{
|
||
int overflow = Mathf.Max(0, ally.lastHealOverflowAmount);
|
||
if (overflow <= 0) return true;
|
||
|
||
int manaGain = Mathf.FloorToInt(overflow * 0.07f);
|
||
if (manaGain > 0)
|
||
{
|
||
ally.ModifyMana(manaGain, true, true);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillDuelStart(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (!_equipDuelStatesBySlot.TryGetValue(slotIndex, out EquipDuelState state))
|
||
{
|
||
state = new EquipDuelState();
|
||
_equipDuelStatesBySlot[slotIndex] = state;
|
||
}
|
||
|
||
if (state.disabled)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
state.hpLossPerSecond = 12;
|
||
state.scorePerSecond = 6;
|
||
|
||
if (state.routine != null)
|
||
{
|
||
StopCoroutine(state.routine);
|
||
}
|
||
|
||
state.routine = StartCoroutine(EquipDuelRoutine(slotIndex, ally));
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillDuelDouble(int slotIndex)
|
||
{
|
||
if (_equipDuelStatesBySlot.TryGetValue(slotIndex, out EquipDuelState state) && !state.disabled)
|
||
{
|
||
state.hpLossPerSecond *= 2;
|
||
state.scorePerSecond *= 2;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillDuelDisable(int slotIndex)
|
||
{
|
||
if (_equipDuelStatesBySlot.TryGetValue(slotIndex, out EquipDuelState state))
|
||
{
|
||
state.disabled = true;
|
||
if (state.routine != null)
|
||
{
|
||
StopCoroutine(state.routine);
|
||
state.routine = null;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator EquipDuelRoutine(int slotIndex, AllyCombatant ally)
|
||
{
|
||
while (ally != null && !ally.IsDead)
|
||
{
|
||
if (!_equipDuelStatesBySlot.TryGetValue(slotIndex, out EquipDuelState state) || state.disabled)
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
ally.ModifyHP(-Mathf.Max(0, state.hpLossPerSecond), true);
|
||
if (ally == null || ally.IsDead)
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
ally.AddScoreDirect(Mathf.Max(0, state.scorePerSecond));
|
||
yield return GameplayClock.WaitForSeconds(1f);
|
||
}
|
||
}
|
||
|
||
private bool HandleEquipSkillFifthManaRestore(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
int count = 0;
|
||
_equipManaFullSpendCountBySlot.TryGetValue(slotIndex, out count);
|
||
count++;
|
||
_equipManaFullSpendCountBySlot[slotIndex] = count;
|
||
|
||
if (count % 5 == 0)
|
||
{
|
||
ally.SetCurrentMana(ally.maxMana, true, true);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillMaxHpToAttack(AllyCombatant ally)
|
||
{
|
||
int hpIncrease = Mathf.Max(0, ally.lastMaxHPIncreaseAmount);
|
||
int times = hpIncrease / 200;
|
||
if (times <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
int attackGainPerStep = Mathf.CeilToInt(ally.maxHP * 0.03f);
|
||
int totalAttackGain = Mathf.Max(0, attackGainPerStep * times);
|
||
if (totalAttackGain > 0)
|
||
{
|
||
ally.ModifyAttack(totalAttackGain);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillSelfDamagedGrowHp(AllyCombatant ally)
|
||
{
|
||
if (ally.lastDamageTakenAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
int increase = Mathf.CeilToInt(ally.maxHP * 0.02f);
|
||
if (increase > 0)
|
||
{
|
||
ally.SetMaxHP(ally.maxHP + increase, false);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillAdjacentDamagedAddScore(AllyCombatant ally)
|
||
{
|
||
int scoreGain = Mathf.Max(0, ally.lastAdjacentAllyDamageTakenAmount);
|
||
if (scoreGain > 0)
|
||
{
|
||
ally.AddScoreDirect(scoreGain);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillStartSetMana(AllyCombatant ally)
|
||
{
|
||
int newMaxMana = Mathf.Max(1, Mathf.CeilToInt(ally.maxHP * 0.5f));
|
||
ally.SetMaxMana(newMaxMana, false);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillKillRefillMana(AllyCombatant ally)
|
||
{
|
||
ally.SetCurrentMana(ally.maxMana, true, true);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillManaSpendReduceCap(AllyCombatant ally)
|
||
{
|
||
if (ally.lastManaSpentAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
ally.SetMaxMana(Mathf.Max(1, ally.maxMana - 100), false);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillVacantPerfectMana(AllyCombatant ally)
|
||
{
|
||
ally.ModifyMana(1, true, true);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillVacantMissHeal(AllyCombatant ally)
|
||
{
|
||
int healAmount = Mathf.Max(0, ally.attack);
|
||
if (healAmount > 0)
|
||
{
|
||
ally.ModifyHP(healAmount, true);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillThousandthTracker(int slotIndex)
|
||
{
|
||
_equipThousandthActiveSlots.Add(slotIndex);
|
||
_equipThousandthAppliedBonusBySlot[slotIndex] = 0f;
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillForgetfulRhythm(int slotIndex)
|
||
{
|
||
_equipForgetfulRhythmActiveSlots.Add(slotIndex);
|
||
_equipForgetfulRhythmStacksBySlot[slotIndex] = 0;
|
||
if (!_equipForgetfulRhythmSeenSkillIdsBySlot.ContainsKey(slotIndex))
|
||
{
|
||
_equipForgetfulRhythmSeenSkillIdsBySlot[slotIndex] = new HashSet<string>();
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillTalentScout(int slotIndex)
|
||
{
|
||
AllyHero_SO hero = GetAllyHeroSOBySlot(slotIndex);
|
||
if (hero == null) return true;
|
||
|
||
List<SkillDefinition> candidates = new List<SkillDefinition>();
|
||
HashSet<string> equippedIds = new HashSet<string>();
|
||
int[] equippedGroups = hero.GetEffectiveEquippedSkillGroupIDs();
|
||
if (equippedGroups != null)
|
||
{
|
||
for (int i = 0; i < equippedGroups.Length; i++)
|
||
{
|
||
SkillGroup equippedGroup = hero.GetSkillGroupByID(equippedGroups[i]);
|
||
if (equippedGroup == null || equippedGroup.skills == null) continue;
|
||
for (int j = 0; j < equippedGroup.skills.Length; j++)
|
||
{
|
||
SkillDefinition equippedDef = equippedGroup.skills[j];
|
||
if (equippedDef != null && !string.IsNullOrWhiteSpace(equippedDef.skillId))
|
||
{
|
||
equippedIds.Add(equippedDef.skillId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hero.availableSkills != null)
|
||
{
|
||
for (int i = 0; i < hero.availableSkills.Length; i++)
|
||
{
|
||
SkillDefinition def = hero.availableSkills[i];
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) continue;
|
||
if (equippedIds.Contains(def.skillId)) continue;
|
||
candidates.Add(def);
|
||
}
|
||
}
|
||
|
||
if (hero.skillGroups != null)
|
||
{
|
||
for (int i = 0; i < hero.skillGroups.Length; i++)
|
||
{
|
||
SkillGroup group = hero.skillGroups[i];
|
||
if (group == null || group.skills == null) continue;
|
||
bool groupEquipped = false;
|
||
if (equippedGroups != null)
|
||
{
|
||
for (int j = 0; j < equippedGroups.Length; j++)
|
||
{
|
||
if (equippedGroups[j] == group.skillGroupID)
|
||
{
|
||
groupEquipped = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (groupEquipped) continue;
|
||
|
||
for (int j = 0; j < group.skills.Length; j++)
|
||
{
|
||
SkillDefinition def = group.skills[j];
|
||
if (def == null || string.IsNullOrWhiteSpace(def.skillId)) continue;
|
||
if (equippedIds.Contains(def.skillId)) continue;
|
||
candidates.Add(def);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (candidates.Count <= 0) return true;
|
||
|
||
SkillDefinition chosen = candidates[UnityEngine.Random.Range(0, candidates.Count)];
|
||
if (chosen == null) return true;
|
||
|
||
string heroObsession = hero != null ? (hero.obsessionTag ?? string.Empty).Trim() : string.Empty;
|
||
string chosenObsession = chosen != null ? (chosen.obsessionTag ?? string.Empty).Trim() : string.Empty;
|
||
bool obsessionMismatch =
|
||
!string.IsNullOrEmpty(heroObsession) &&
|
||
!string.IsNullOrEmpty(chosenObsession) &&
|
||
!string.Equals(heroObsession, chosenObsession, StringComparison.Ordinal);
|
||
if (obsessionMismatch)
|
||
{
|
||
ApplyTalentScoutDisagree(slotIndex);
|
||
}
|
||
|
||
_equipTalentScoutLastBorrowedSkillBySlot[slotIndex] = chosen.skillId;
|
||
UseSkillDefinition(chosen, slotIndex, -1f, null);
|
||
return true;
|
||
}
|
||
|
||
private void ApplyTalentScoutDisagree(int slotIndex)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(slotIndex);
|
||
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally == null || ally.IsDead) return;
|
||
|
||
if (_equipTalentScoutDisagreeCoroutinesBySlot.TryGetValue(slotIndex, out Coroutine existing) && existing != null)
|
||
{
|
||
StopCoroutine(existing);
|
||
_equipTalentScoutDisagreeCoroutinesBySlot.Remove(slotIndex);
|
||
if (_equipTalentScoutDisagreeRestoreScoreBySlot.TryGetValue(slotIndex, out float oldScore))
|
||
{
|
||
ally.scoreEfficiency = Mathf.Max(0f, oldScore);
|
||
}
|
||
}
|
||
|
||
_equipTalentScoutDisagreeRestoreScoreBySlot[slotIndex] = ally.scoreEfficiency;
|
||
ally.scoreEfficiency = 0f;
|
||
_equipTalentScoutDisagreeCoroutinesBySlot[slotIndex] = StartCoroutine(TalentScoutDisagreeCoroutine(slotIndex, ally, 2f));
|
||
}
|
||
|
||
private IEnumerator TalentScoutDisagreeCoroutine(int slotIndex, AllyCombatant ally, float duration)
|
||
{
|
||
if (ally != null)
|
||
{
|
||
iBudeffPrefabController.Instance?.RegisterTimedEffect(ally, PlayerBudeffIconType.ot_scoreEfficiency_down, -ally.scoreEfficiency, duration);
|
||
}
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (ally != null && !ally.IsDead && _equipTalentScoutDisagreeRestoreScoreBySlot.TryGetValue(slotIndex, out float restore))
|
||
{
|
||
ally.scoreEfficiency = Mathf.Max(0f, restore);
|
||
}
|
||
_equipTalentScoutDisagreeCoroutinesBySlot.Remove(slotIndex);
|
||
_equipTalentScoutDisagreeRestoreScoreBySlot.Remove(slotIndex);
|
||
}
|
||
|
||
private bool HandleEquipSkillOutOfLineManaScore(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (ally.lastManaSpentAmount <= 0) return true;
|
||
|
||
int killCount = 0;
|
||
_equipOutOfLineKillCountsBySlot.TryGetValue(slotIndex, out killCount);
|
||
ally.AddScoreDirect(75 + killCount * 55);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillOutOfLineEnemyDead(int slotIndex)
|
||
{
|
||
int current = 0;
|
||
_equipOutOfLineKillCountsBySlot.TryGetValue(slotIndex, out current);
|
||
_equipOutOfLineKillCountsBySlot[slotIndex] = current + 1;
|
||
SwapEquippedMemoryWithLowerAdjacent(slotIndex);
|
||
return true;
|
||
}
|
||
|
||
private void SwapEquippedMemoryWithLowerAdjacent(int slotIndex)
|
||
{
|
||
int lowerSlot = (slotIndex + 1) % 5;
|
||
if (lowerSlot < 0 || lowerSlot >= 5 || lowerSlot == slotIndex) return;
|
||
|
||
AllyHero_SO self = GetAllyHeroSOBySlot(slotIndex);
|
||
AllyHero_SO lower = GetAllyHeroSOBySlot(lowerSlot);
|
||
if (self == null || lower == null) return;
|
||
|
||
equipmentSO selfEquip = self.GetEquippedEquipmentResolved();
|
||
equipmentSO lowerEquip = lower.GetEquippedEquipmentResolved();
|
||
self.SetEquippedEquipment(lowerEquip, false);
|
||
lower.SetEquippedEquipment(selfEquip, false);
|
||
}
|
||
|
||
private bool HandleEquipSkillStormStart(int slotIndex, AllyCombatant ally)
|
||
{
|
||
int reduce = Mathf.FloorToInt(ally.attack * 0.46f);
|
||
if (reduce > 0)
|
||
{
|
||
ally.ModifyAttack(-reduce);
|
||
}
|
||
_equipStormStacksBySlot[slotIndex] = 1;
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillMissionStart(AllyCombatant ally)
|
||
{
|
||
int bonusAttack = Mathf.FloorToInt(ally.damageResistance / 0.01f);
|
||
if (bonusAttack > 0)
|
||
{
|
||
ally.ModifyAttack(bonusAttack);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillMissionFullMana(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0) return true;
|
||
ally.damageResistance = Mathf.Clamp01(ally.damageResistance + 0.04f);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillAllForYouMana(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0) return true;
|
||
ally.ActivateSelfDamageRedirectToAdjacent(999f);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillAllForYouKill(AllyCombatant ally)
|
||
{
|
||
ally.ActivateSelfDamageRedirectToAdjacent(999f);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillFateRelianceStart(int slotIndex)
|
||
{
|
||
_equipFateRelianceActiveSlots.Add(slotIndex);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillNewIdeaStack(int slotIndex, float duration)
|
||
{
|
||
int stacks = 0;
|
||
_equipNewIdeaStacksBySlot.TryGetValue(slotIndex, out stacks);
|
||
_equipNewIdeaStacksBySlot[slotIndex] = stacks + 1;
|
||
|
||
if (!_equipNewIdeaExpireCoroutinesBySlot.TryGetValue(slotIndex, out List<Coroutine> list) || list == null)
|
||
{
|
||
list = new List<Coroutine>();
|
||
_equipNewIdeaExpireCoroutinesBySlot[slotIndex] = list;
|
||
}
|
||
|
||
Coroutine expire = StartCoroutine(NewIdeaExpireCoroutine(slotIndex, duration));
|
||
list.Add(expire);
|
||
ApplyNewIdeaState(slotIndex);
|
||
return true;
|
||
}
|
||
|
||
private IEnumerator NewIdeaExpireCoroutine(int slotIndex, float duration)
|
||
{
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (_equipNewIdeaStacksBySlot.TryGetValue(slotIndex, out int stacks))
|
||
{
|
||
_equipNewIdeaStacksBySlot[slotIndex] = Mathf.Max(0, stacks - 1);
|
||
}
|
||
ApplyNewIdeaState(slotIndex);
|
||
}
|
||
|
||
private void ApplyNewIdeaState(int slotIndex)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(slotIndex);
|
||
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
AllyHero_SO hero = GetAllyHeroSOBySlot(slotIndex);
|
||
if (ally == null || hero == null || ally.IsDead) return;
|
||
|
||
int stacks = 0;
|
||
_equipNewIdeaStacksBySlot.TryGetValue(slotIndex, out stacks);
|
||
|
||
List<AllyHero_SO.AllyLevelInfo> sortedLevels = new List<AllyHero_SO.AllyLevelInfo>();
|
||
if (hero.levelStats != null)
|
||
{
|
||
for (int i = 0; i < hero.levelStats.Count; i++)
|
||
{
|
||
if (hero.levelStats[i] != null) sortedLevels.Add(hero.levelStats[i]);
|
||
}
|
||
}
|
||
if (sortedLevels.Count <= 0) return;
|
||
sortedLevels.Sort((left, right) => left.requiredEXP.CompareTo(right.requiredEXP));
|
||
|
||
int baseTierIndex = Mathf.Clamp((int)AllyHeroGrowthService.GetUnlockedTier(hero), 0, sortedLevels.Count - 1);
|
||
int targetTierIndex = Mathf.Clamp(baseTierIndex + stacks, 0, Mathf.Min(sortedLevels.Count - 1, 3));
|
||
AllyHero_SO.AllyLevelInfo targetInfo = hero.GetEffectiveLevelInfoWithEquipment(sortedLevels[targetTierIndex]);
|
||
if (targetInfo == null) return;
|
||
|
||
int currentHp = ally.currentHP;
|
||
int currentMana = ally.currentMana;
|
||
ally.SetMaxHP(Mathf.Max(1, targetInfo.maxHP), false);
|
||
ally.SetMaxMana(Mathf.Max(1, targetInfo.maxMana), false);
|
||
ally.SetAttack(Mathf.Max(0, targetInfo.attack));
|
||
ally.scoreEfficiency = Mathf.Max(0f, targetInfo.scoreEfficiency);
|
||
ally.damageResistance = Mathf.Clamp01(targetInfo.damageResistance);
|
||
ally.SetCurrentHP(Mathf.Clamp(currentHp, 0, ally.maxHP), false);
|
||
ally.SetCurrentMana(Mathf.Clamp(currentMana, 0, ally.maxMana), false, false);
|
||
}
|
||
|
||
private bool HandleEquipSkillNamelessLaneKill(int slotIndex)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(slotIndex);
|
||
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
teamUIController.Instance?.SwapAllyWithLowerAdjacent(slotIndex);
|
||
if (ally != null && !ally.IsDead)
|
||
{
|
||
ally.scoreEfficiency += 0.012f;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillSocialMaskStart(int slotIndex, AllyCombatant ally)
|
||
{
|
||
ActivateSocialMask(slotIndex, ally);
|
||
return true;
|
||
}
|
||
|
||
private void ActivateSocialMask(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (ally == null || ally.IsDead) return;
|
||
if (_equipSocialMaskActiveSlots.Contains(slotIndex)) return;
|
||
|
||
_equipSocialMaskActiveSlots.Add(slotIndex);
|
||
_equipSocialMaskOriginalMaxHpBySlot[slotIndex] = ally.maxHP;
|
||
_equipSocialMaskOriginalScoreBySlot[slotIndex] = ally.scoreEfficiency;
|
||
|
||
int newMaxHp = Mathf.Max(1, Mathf.FloorToInt(ally.maxHP * 0.20f));
|
||
ally.SetMaxHP(newMaxHp, false);
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency * 0.60f);
|
||
}
|
||
|
||
private bool HandleEquipSkillSocialMaskKill(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (ally == null || ally.IsDead) return true;
|
||
if (!_equipSocialMaskActiveSlots.Contains(slotIndex)) return true;
|
||
|
||
ally.ModifyAttack(2);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillSocialMaskClear(int slotIndex, AllyCombatant ally)
|
||
{
|
||
DeactivateSocialMask(slotIndex, ally);
|
||
return true;
|
||
}
|
||
|
||
private void DeactivateSocialMask(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (ally == null) return;
|
||
if (!_equipSocialMaskActiveSlots.Remove(slotIndex)) return;
|
||
|
||
int currentAttack = ally.attack;
|
||
if (_equipSocialMaskOriginalMaxHpBySlot.TryGetValue(slotIndex, out int originalMaxHp))
|
||
{
|
||
ally.SetMaxHP(Mathf.Max(1, originalMaxHp), false);
|
||
ally.SetCurrentHP(ally.maxHP, true);
|
||
}
|
||
|
||
float baseScore = ally.scoreEfficiency;
|
||
if (_equipSocialMaskOriginalScoreBySlot.TryGetValue(slotIndex, out float originalScore))
|
||
{
|
||
baseScore = originalScore;
|
||
}
|
||
|
||
ally.SetAttack(0);
|
||
ally.scoreEfficiency = Mathf.Max(0f, baseScore + currentAttack * 0.01f);
|
||
_equipSocialMaskOriginalMaxHpBySlot.Remove(slotIndex);
|
||
_equipSocialMaskOriginalScoreBySlot.Remove(slotIndex);
|
||
}
|
||
|
||
private bool HandleEquipSkillQuietTurn(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0) return true;
|
||
|
||
int selfHeal = Mathf.CeilToInt(ally.maxHP * 0.01f);
|
||
if (selfHeal > 0)
|
||
{
|
||
ally.ModifyHP(selfHeal, true);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillQuietTurnHealAdjacent(AllyCombatant ally)
|
||
{
|
||
if (ally == null || ally.IsDead) return true;
|
||
if (ally.lastHealActualAmount <= 0) return true;
|
||
if (teamUIController.Instance == null) return true;
|
||
if (_equipQuietTurnBroadcastInProgress) return true;
|
||
|
||
_equipQuietTurnBroadcastInProgress = true;
|
||
try
|
||
{
|
||
int[] adjacent = teamUIController.Instance.GetAdjacentAllyIndices(ally.slotIndex);
|
||
for (int i = 0; i < adjacent.Length; i++)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(adjacent[i]);
|
||
AllyCombatant adj = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (adj == null || adj.IsDead) continue;
|
||
int manaGain = Mathf.CeilToInt(Mathf.Max(0, ally.attack) * 0.30f);
|
||
if (manaGain > 0)
|
||
{
|
||
adj.ModifyMana(manaGain, true, true);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_equipQuietTurnBroadcastInProgress = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillBlock(AllyCombatant ally)
|
||
{
|
||
int healAmount = Mathf.CeilToInt(ally.maxHP * 0.10f);
|
||
if (teamUIController.Instance != null)
|
||
{
|
||
int[] adjacent = teamUIController.Instance.GetAdjacentAllyIndices(ally.slotIndex);
|
||
for (int i = 0; i < adjacent.Length; i++)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(adjacent[i]);
|
||
AllyCombatant adj = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (adj == null || adj.IsDead) continue;
|
||
if (healAmount > 0) adj.ModifyHP(healAmount, true);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillBlockStart(AllyCombatant ally)
|
||
{
|
||
int increase = Mathf.CeilToInt(ally.maxHP * 0.05f);
|
||
if (increase > 0)
|
||
{
|
||
ally.SetMaxHP(ally.maxHP + increase, false);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillFutureStart(int slotIndex)
|
||
{
|
||
_equipFutureActiveSlots.Add(slotIndex);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillFutureHeal(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (ally == null || ally.IsDead) return true;
|
||
if (!_equipFutureActiveSlots.Contains(slotIndex)) return true;
|
||
if (teamUIController.Instance == null) return true;
|
||
|
||
int healedAmount = Mathf.Max(0, ally.lastHealActualAmount);
|
||
if (healedAmount <= 0) return true;
|
||
|
||
int bonusMaxHp = Mathf.CeilToInt(healedAmount * 0.10f);
|
||
if (bonusMaxHp <= 0) return true;
|
||
|
||
int[] adjacent = teamUIController.Instance.GetAdjacentAllyIndices(slotIndex);
|
||
for (int i = 0; i < adjacent.Length; i++)
|
||
{
|
||
GameObject allyGo = GetAllyObjectBySlot(adjacent[i]);
|
||
AllyCombatant adj = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (adj == null || adj.IsDead) continue;
|
||
adj.SetMaxHP(adj.maxHP + bonusMaxHp, false);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleEquipSkillFutureClear(int slotIndex, AllyCombatant ally)
|
||
{
|
||
if (_equipFutureActiveSlots.Remove(slotIndex) && ally != null && !ally.IsDead)
|
||
{
|
||
ally.scoreEfficiency += 0.02f;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillBole14StartMaxHp(AllyCombatant ally)
|
||
{
|
||
int increase = Mathf.CeilToInt(ally.maxHP * 0.02f);
|
||
if (increase > 0)
|
||
{
|
||
ally.SetMaxHP(ally.maxHP + increase, false);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillBole6SpendFullMaxHp(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
int increase = Mathf.CeilToInt(ally.maxHP * 0.01f);
|
||
if (increase > 0)
|
||
{
|
||
ally.SetMaxHP(ally.maxHP + increase, false);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillScarborough28StartAttack(AllyCombatant ally)
|
||
{
|
||
ally.ModifyAttack(2);
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillScarborough33SpendFullScore(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
ally.AddScoreDirect(Mathf.Max(0, ally.attack));
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillBole7KillHeal(AllyCombatant ally)
|
||
{
|
||
int healAmount = Mathf.CeilToInt(ally.maxHP * 0.03f);
|
||
if (healAmount > 0)
|
||
{
|
||
ally.ModifyHP(healAmount, true);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private bool HandleIllusionSkillCraft9SpendFullMana(AllyCombatant ally)
|
||
{
|
||
if (!ally.lastManaSpendWasFullBar || ally.lastManaSpentAmount <= 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
ally.ModifyMana(10, true, true);
|
||
return true;
|
||
}
|
||
|
||
// 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)
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
if (group == null) continue;
|
||
UseSkillGroupForSlot(group, slotIndex, inputValue, specificTarget);
|
||
}
|
||
return;
|
||
}
|
||
Debug.LogWarning($"UsePrimarySkillForSlot: no equipped skill groups for slot {slotIndex}");
|
||
}
|
||
|
||
// Cast all non-null skills in a SkillGroup for a given slotIndex. Each skill is invoked via UseSkillDefinition.
|
||
public void UseSkillGroupForSlot(SkillGroup group, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||
{
|
||
if (group == null) return;
|
||
for (int i = 0; i < group.skills.Length; i++)
|
||
{
|
||
var s = group.skills[i];
|
||
if (s == null) continue;
|
||
UseSkillDefinition(s, slotIndex, inputValue, specificTarget);
|
||
LogVerbose($"[SkillBuilder] UseSkillGroupForSlot: cast skill {s.skillId} from group '{group.groupName}' for slot {slotIndex}");
|
||
}
|
||
}
|
||
|
||
// Called at game start to trigger any allies whose primary skill is set to trigger on game start
|
||
public void TriggerOnGameStart()
|
||
{
|
||
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return;
|
||
foreach (var kv in _equipDuelStatesBySlot)
|
||
{
|
||
if (kv.Value != null && kv.Value.routine != null)
|
||
{
|
||
StopCoroutine(kv.Value.routine);
|
||
kv.Value.routine = null;
|
||
}
|
||
}
|
||
_equipDuelStatesBySlot.Clear();
|
||
_equipManaFullSpendCountBySlot.Clear();
|
||
_equipGhoulSchoolActiveSlots.Clear();
|
||
_equipGhoulSchoolSeenSkillIdsBySlot.Clear();
|
||
foreach (var kv in _equipThousandthAppliedBonusBySlot)
|
||
{
|
||
var allyGo = GetAllyObjectBySlot(kv.Key);
|
||
var ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null)
|
||
{
|
||
ally.scoreEfficiency -= kv.Value;
|
||
}
|
||
}
|
||
_equipThousandthActiveSlots.Clear();
|
||
_equipThousandthAppliedBonusBySlot.Clear();
|
||
_equipForgetfulRhythmActiveSlots.Clear();
|
||
_equipForgetfulRhythmSeenSkillIdsBySlot.Clear();
|
||
_equipForgetfulRhythmStacksBySlot.Clear();
|
||
foreach (var kv in _equipTalentScoutDisagreeCoroutinesBySlot)
|
||
{
|
||
if (kv.Value != null) StopCoroutine(kv.Value);
|
||
}
|
||
foreach (var kv in _equipTalentScoutDisagreeRestoreScoreBySlot)
|
||
{
|
||
var allyGo = GetAllyObjectBySlot(kv.Key);
|
||
var ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null) ally.scoreEfficiency = Mathf.Max(0f, kv.Value);
|
||
}
|
||
_equipTalentScoutLastBorrowedSkillBySlot.Clear();
|
||
_equipTalentScoutDisagreeCoroutinesBySlot.Clear();
|
||
_equipTalentScoutDisagreeRestoreScoreBySlot.Clear();
|
||
_equipOutOfLineKillCountsBySlot.Clear();
|
||
_equipStormStacksBySlot.Clear();
|
||
_equipStormProcessingSlots.Clear();
|
||
_equipFateRelianceActiveSlots.Clear();
|
||
foreach (var kv in _equipNewIdeaExpireCoroutinesBySlot)
|
||
{
|
||
if (kv.Value == null) continue;
|
||
for (int i = 0; i < kv.Value.Count; i++)
|
||
{
|
||
if (kv.Value[i] != null) StopCoroutine(kv.Value[i]);
|
||
}
|
||
}
|
||
_equipNewIdeaExpireCoroutinesBySlot.Clear();
|
||
_equipNewIdeaStacksBySlot.Clear();
|
||
_equipSocialMaskActiveSlots.Clear();
|
||
_equipSocialMaskOriginalMaxHpBySlot.Clear();
|
||
_equipSocialMaskOriginalScoreBySlot.Clear();
|
||
_equipFutureActiveSlots.Clear();
|
||
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
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
if (group == null) continue;
|
||
foreach (var sk in group.skills)
|
||
{
|
||
if (sk == null) continue;
|
||
if (sk.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||
{
|
||
LogVerbose($"[SkillBuilder] TriggerOnGameStart: casting equipped group skill {sk.skillId} for slot {i}");
|
||
UseSkillDefinition(sk, i, -1f, null);
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
if (group == null) continue;
|
||
foreach (var def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyDead) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyDead: slot {i+1} casting skill {def.skillId}");
|
||
// Only pass deadEnemy as specificTarget when the skill truly expects a specific target (e.g. targets CurrentEnemies)
|
||
GameObject ctxTarget = null;
|
||
if (deadEnemy != null && (def.requiresSpecificTarget || def.defaultSelector == Selector.CurrentEnemies)) ctxTarget = deadEnemy.gameObject;
|
||
UseSkillDefinition(def, i, -1f, ctxTarget);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
if (group == null) continue;
|
||
foreach (var def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnEnemyRevive) continue;
|
||
LogVerbose($"[SkillBuilder] TriggerOnEnemyRevive: slot {i+1} casting skill {def.skillId}");
|
||
UseSkillDefinition(def, i, -1f, enemy != null ? enemy.gameObject : null);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger ally skills when an adjacent ally releases a skill (mana-full cast path).
|
||
public void TriggerOnAdjacentAllySkillCast(int casterSlotIndex)
|
||
{
|
||
if (!Application.isPlaying) return;
|
||
if (casterSlotIndex < 0) return;
|
||
|
||
int slots = 5;
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||
slots = teamUIController.Instance.allySlotIds.Count;
|
||
|
||
for (int i = 0; i < slots; i++)
|
||
{
|
||
if (i == casterSlotIndex) continue;
|
||
|
||
bool isAdjacent = false;
|
||
if (teamUIController.Instance != null)
|
||
{
|
||
int[] adj = teamUIController.Instance.GetAdjacentAllyIndices(i);
|
||
if (adj != null)
|
||
{
|
||
for (int j = 0; j < adj.Length; j++)
|
||
{
|
||
if (adj[j] == casterSlotIndex) { isAdjacent = true; break; }
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
isAdjacent = Mathf.Abs(i - casterSlotIndex) == 1;
|
||
}
|
||
|
||
if (!isAdjacent) continue;
|
||
|
||
var allyObj = GetAllyObjectBySlot(i);
|
||
var allyComp = allyObj != null ? allyObj.GetComponent<AllyCombatant>() : null;
|
||
if (allyComp != null && allyComp.IsDead) continue;
|
||
|
||
var so = GetAllyHeroSOBySlot(i);
|
||
if (so == null) continue;
|
||
|
||
int[] runtimeGroupIds = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIds != null && runtimeGroupIds.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIds)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
if (group == null || group.skills == null) continue;
|
||
|
||
foreach (var def in group.skills)
|
||
{
|
||
if (def == null) continue;
|
||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnAdjacentAllySkillCast) continue;
|
||
UseSkillDefinition(def, i, -1f, null);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keyed by (trackIndex, skillId) value-tuple instead of an interpolated string, so the
|
||
// per-note-hit cooldown lookup allocates nothing. Only touched when a skill actually
|
||
// configures onNoteHitCooldown > 0.
|
||
private Dictionary<(int, string), float> _lastOnNoteHitTriggerTime = new Dictionary<(int, 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)
|
||
{
|
||
// Guard interpolation: this runs on every note hit; the $"..." would otherwise
|
||
// build a string + box the enum each hit even when verbose logging is off.
|
||
if (GameConfig.verboseLogs)
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}");
|
||
// cleanup old processed ids (> 10s)
|
||
float nowCleanup = GameplayClock.NowSongTime;
|
||
_tmpNoteIdRemoval.Clear();
|
||
var toRemove = _tmpNoteIdRemoval;
|
||
foreach (var kv in _processedNoteHitTimestamps)
|
||
{
|
||
if (nowCleanup - kv.Value > 10f) toRemove.Add(kv.Key);
|
||
}
|
||
foreach (var k in toRemove) _processedNoteHitTimestamps.Remove(k);
|
||
|
||
// Determine whether to apply shared effects (mana/H P loss) for this event.
|
||
bool applyShared = true;
|
||
if (!string.IsNullOrEmpty(uniqueNoteId))
|
||
{
|
||
if (_processedNoteHitTimestamps.ContainsKey(uniqueNoteId))
|
||
{
|
||
applyShared = false; // already applied for this hold note
|
||
}
|
||
else
|
||
{
|
||
_processedNoteHitTimestamps[uniqueNoteId] = nowCleanup;
|
||
}
|
||
}
|
||
|
||
// Always apply shared per-note behavior (mana gain / miss penalty / reserved damage) unless this unique note id was already processed
|
||
if (applyShared)
|
||
{
|
||
try { ApplySharedOnNoteHit(trackIndex, judgeResult); } catch (System.Exception ex) { Debug.LogError($"[SkillBuilder] ApplySharedOnNoteHit threw: {ex}"); }
|
||
}
|
||
|
||
// Dead allies should not trigger any note-hit skills.
|
||
try
|
||
{
|
||
var allyGO = GetAllyObjectBySlot(trackIndex);
|
||
var ally = allyGO != null ? allyGO.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null && ally.IsDead)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: slot {trackIndex} is dead -> ignore skill triggers.");
|
||
return false;
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
if (trackIndex < 0) { LogVerbose("[SkillBuilder] NotifyNoteHit: invalid trackIndex"); return false; }
|
||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||
if (so == null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: no SO found for slot {trackIndex}, attempting fallback resolution...");
|
||
// Dump current UI slot ids for diagnostics
|
||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] teamUIController.allySlotIds = [{string.Join(",", teamUIController.Instance.allySlotIds)}]");
|
||
}
|
||
// Try to find ally GameObject named by convention
|
||
var foundGO = SceneObjectLookupCache.Find($"ally_0{trackIndex + 1}");
|
||
if (foundGO != null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] Found GameObject by name ally_0{trackIndex + 1}: {foundGO.name}");
|
||
// try to map to UI slot
|
||
var ui = teamUIController.Instance;
|
||
if (ui != null && ui.allySlotIds != null)
|
||
{
|
||
for (int i = 0; i < ui.allySlotIds.Count; i++)
|
||
{
|
||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||
if (slotObj == null) continue;
|
||
if (slotObj == foundGO || foundGO.transform.IsChildOf(slotObj.transform))
|
||
{
|
||
LogVerbose($"[SkillBuilder] Resolved fallback slot {i} for GameObject {foundGO.name}");
|
||
trackIndex = i; // override
|
||
so = GetAllyHeroSOBySlot(trackIndex);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (so == null)
|
||
{
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: still no SO for resolved slot {trackIndex}, aborting NotifyNoteHit");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
HandleEquipNoteJudgePassives(trackIndex, judgeResult);
|
||
|
||
bool anyTriggered = false;
|
||
|
||
// If equipped groups exist, iterate equipped groups and check each skill for OnNoteHit
|
||
int[] runtimeGroupIdsForNotes = so.GetEffectiveEquippedSkillGroupIDs();
|
||
if (runtimeGroupIdsForNotes != null && runtimeGroupIdsForNotes.Length > 0)
|
||
{
|
||
foreach (int gid in runtimeGroupIdsForNotes)
|
||
{
|
||
if (gid == 0) continue;
|
||
SkillGroup group = so.GetSkillGroupByID(gid);
|
||
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) — tuple key, no per-hit string alloc
|
||
float now = GameplayClock.NowSongTime;
|
||
bool hasCooldown = def.onNoteHitCooldown > 0f;
|
||
(int, string) key = default;
|
||
if (hasCooldown)
|
||
{
|
||
key = (trackIndex, def.skillId);
|
||
if (_lastOnNoteHitTriggerTime.TryGetValue(key, out float last))
|
||
{
|
||
if (now - last < def.onNoteHitCooldown) continue;
|
||
}
|
||
}
|
||
|
||
// trigger
|
||
if (GameConfig.verboseLogs)
|
||
LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||
UseSkillDefinition(def, trackIndex, -1f, null);
|
||
if (hasCooldown) _lastOnNoteHitTriggerTime[key] = now;
|
||
anyTriggered = true;
|
||
}
|
||
}
|
||
return anyTriggered;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/*
|
||
Explanation of caster and enemyTarget:
|
||
- GameObject caster: the GameObject that performs (casts) the skill. For ally skills this should be the in-scene ally GameObject (usually name ally_01..ally_05) and should have AllyCombatant / ICombatant components. SkillBuilder uses slotIndex -> GetAllyObjectBySlot to resolve this.
|
||
- GameObject specificTarget / enemyTarget: when a skill targets a specific unit (single enemy or ally), pass that GameObject here. If null, the selected Selector determines the set of targets (e.g. CurrentEnemies without specificTarget will resolve all enemies). Use Enemy GameObjects found by tag 'Enemy' or name 'thisEnemy', or keep a reference to the spawned enemy instance you want.
|
||
|
||
How to assign/choose:
|
||
- Caster: when you call from gameplay code, pass the caster GameObject (or call UseSelectedSkillForSlot with slot index so SkillBuilder resolves caster for you).
|
||
- EnemyTarget: if your skill should hit a specific enemy instance, supply that enemy's GameObject (for example from Enemy spawn manager or from collision detection). If you want to apply to all enemies, pass null and use Selector.CurrentEnemies.
|
||
*/
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public void _ally03skill01(EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null)
|
||
{
|
||
AllySlotSkill(2, "skill01", effectType, value, selector, duration, specificTarget);
|
||
}
|
||
|
||
// Coroutine helper for IncreaseManaOverTime
|
||
private IEnumerator ApplyIncreaseManaOverTimeDirect(AllyCombatant ally, float totalAmount, float duration, float tickInterval)
|
||
{
|
||
if (ally == null) yield break;
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
ally.ModifyMana(Mathf.CeilToInt(totalAmount), true, true);
|
||
yield break;
|
||
}
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
ally.ModifyMana(Mathf.CeilToInt(perTick), true, true);
|
||
yield return GameplayClock.WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
|
||
private IEnumerator RemoveBuffAfterDuration(GameObject target, string buffId, float duration)
|
||
{
|
||
if (duration <= 0f) yield break;
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
ic?.RemoveBuff(buffId);
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryScoreEfficiencyChangeDirect(AllyCombatant ally, float delta, float duration, string iconId)
|
||
{
|
||
try
|
||
{
|
||
if (ally == null) yield break;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency + delta);
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (ally == null) yield break;
|
||
ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - delta);
|
||
}
|
||
finally
|
||
{
|
||
if (ally != null && !string.IsNullOrEmpty(iconId))
|
||
iBudeffPrefabController.Instance?.UnregisterTimedEffect(ally.slotIndex, iconId);
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryAttackChangeDirect(AllyCombatant ally, int delta, float duration, string iconId)
|
||
{
|
||
try
|
||
{
|
||
if (ally == null) yield break;
|
||
ally.ModifyAttack(delta);
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (ally == null) yield break;
|
||
ally.ModifyAttack(-delta);
|
||
}
|
||
finally
|
||
{
|
||
if (ally != null && !string.IsNullOrEmpty(iconId))
|
||
iBudeffPrefabController.Instance?.UnregisterTimedEffect(ally.slotIndex, iconId);
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyTemporaryAttackChangeDirect(EnemyCombatant enemy, int delta, float duration, string iconId, int enemyInstanceId)
|
||
{
|
||
try
|
||
{
|
||
if (enemy == null) yield break;
|
||
enemy.ModifyAttack(delta);
|
||
iBudeffPrefabController.Instance?.RefreshEnemyNow(enemy);
|
||
yield return GameplayClock.WaitForSeconds(duration);
|
||
if (enemy == null) yield break;
|
||
enemy.ModifyAttack(-delta);
|
||
}
|
||
finally
|
||
{
|
||
if (!string.IsNullOrEmpty(iconId) && enemyInstanceId != 0)
|
||
iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(enemyInstanceId, iconId);
|
||
}
|
||
}
|
||
|
||
// Coroutine helpers for direct DoT/HoT on GameObject targets
|
||
private IEnumerator ApplyDamageOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source, string iconId, int allySlotIndex, int enemyInstanceId)
|
||
{
|
||
try
|
||
{
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null && ic == null)
|
||
{
|
||
Debug.LogWarning($"ApplyDamageOverTimeDirect: target {target.name} cannot receive damage");
|
||
yield break;
|
||
}
|
||
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
if (target.GetComponent<EnemyCombatant>() != null && teamUIController.Instance != null)
|
||
{
|
||
if (!teamUIController.Instance.IsAnyAllyActive())
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
if (source != null)
|
||
{
|
||
var sourceAlly = source.GetComponent<AllyCombatant>();
|
||
if (sourceAlly != null && !teamUIController.Instance.IsAllySlotActive(sourceAlly.slotIndex))
|
||
{
|
||
yield break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(totalAmount), true);
|
||
else ic?.ReceiveDamage(totalAmount, source);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (target == null) yield break;
|
||
|
||
if (target.GetComponent<EnemyCombatant>() != null && teamUIController.Instance != null)
|
||
{
|
||
if (!teamUIController.Instance.IsAnyAllyActive())
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
if (source != null)
|
||
{
|
||
var sourceAlly = source.GetComponent<AllyCombatant>();
|
||
if (sourceAlly != null && !teamUIController.Instance.IsAllySlotActive(sourceAlly.slotIndex))
|
||
{
|
||
yield break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(perTick), true);
|
||
else ic?.ReceiveDamage(perTick, source);
|
||
yield return GameplayClock.WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (!string.IsNullOrEmpty(iconId))
|
||
{
|
||
if (allySlotIndex >= 0) iBudeffPrefabController.Instance?.UnregisterTimedEffect(allySlotIndex, iconId);
|
||
else if (enemyInstanceId != 0) iBudeffPrefabController.Instance?.UnregisterEnemyTimedEffect(enemyInstanceId, iconId);
|
||
}
|
||
}
|
||
}
|
||
|
||
private IEnumerator ApplyHealOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||
{
|
||
if (target == null) yield break;
|
||
var ic = target.GetComponent<ICombatant>();
|
||
var ally = target.GetComponent<AllyCombatant>();
|
||
if (ally == null && ic == null)
|
||
{
|
||
Debug.LogWarning($"ApplyHealOverTimeDirect: target {target.name} cannot receive heal");
|
||
yield break;
|
||
}
|
||
|
||
if (duration <= 0f || tickInterval <= 0f)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(totalAmount, source);
|
||
else ic?.ReceiveHeal(totalAmount, source);
|
||
yield break;
|
||
}
|
||
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||
float perTick = totalAmount / ticks;
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
if (ally != null) ally.ReceiveHeal(perTick, source);
|
||
else ic?.ReceiveHeal(perTick, source);
|
||
yield return GameplayClock.WaitForSeconds(tickInterval);
|
||
elapsed += tickInterval;
|
||
}
|
||
}
|
||
|
||
private static string ResolveCasterDisplayNameForLog(int slotIndex, AllyHero_SO heroSo, GameObject caster)
|
||
{
|
||
if (heroSo != null && !string.IsNullOrWhiteSpace(heroSo.ally_heroName))
|
||
{
|
||
return heroSo.ally_heroName;
|
||
}
|
||
|
||
if (caster != null)
|
||
{
|
||
var ally = caster.GetComponent<AllyCombatant>() ?? caster.GetComponentInChildren<AllyCombatant>(true);
|
||
if (ally != null && !string.IsNullOrWhiteSpace(ally.allyName))
|
||
{
|
||
return ally.allyName;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(caster.name))
|
||
{
|
||
return caster.name;
|
||
}
|
||
}
|
||
|
||
return "slot" + (slotIndex + 1);
|
||
}
|
||
|
||
private static string BuildSkillTargetSummaryForLog(Selector selector, GameObject specificTarget)
|
||
{
|
||
if (specificTarget != null)
|
||
{
|
||
return selector + " -> " + specificTarget.name;
|
||
}
|
||
|
||
return selector.ToString();
|
||
}
|
||
|
||
private static string BuildSkillEffectSummaryForLog(SkillDefinition def, float amountPerTick, float amountTotal)
|
||
{
|
||
if (def == null) return "-";
|
||
|
||
bool isOverTime = IsOverTimeEffectForLog(def.effectType) && def.defaultDuration > 0f;
|
||
string summary;
|
||
|
||
switch (def.effectType)
|
||
{
|
||
case EffectType.DamageSingleEnemy:
|
||
case EffectType.DamageSingleAlly:
|
||
summary = "生命值" + FormatSignedFloatForLog(-Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.DamageOverTimeEnemy:
|
||
case EffectType.DamageOverTimeAlly:
|
||
if (isOverTime) summary = "生命值" + FormatSignedFloatForLog(-Mathf.Abs(amountPerTick)) + "/tick, 总计" + FormatSignedFloatForLog(-Mathf.Abs(amountTotal));
|
||
else summary = "生命值" + FormatSignedFloatForLog(-Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.HealSingleEnemy:
|
||
case EffectType.HealSingleSelf:
|
||
case EffectType.HealGroupSingle:
|
||
summary = "生命值" + FormatSignedFloatForLog(Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.HealOverTimeEnemy:
|
||
case EffectType.HealOverTimeSelf:
|
||
case EffectType.HealGroupOverTime:
|
||
if (isOverTime) summary = "生命值" + FormatSignedFloatForLog(Mathf.Abs(amountPerTick)) + "/tick, 总计" + FormatSignedFloatForLog(Mathf.Abs(amountTotal));
|
||
else summary = "生命值" + FormatSignedFloatForLog(Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.IncreaseManaOverTime:
|
||
if (isOverTime) summary = "法力值" + FormatSignedFloatForLog(amountPerTick) + "/tick, 总计" + FormatSignedFloatForLog(amountTotal);
|
||
else summary = "法力值" + FormatSignedFloatForLog(amountTotal);
|
||
break;
|
||
case EffectType.ReduceEnemyHealOverTime:
|
||
summary = "受疗倍率-" + Mathf.RoundToInt(Mathf.Abs(amountPerTick) * 100f) + "%";
|
||
break;
|
||
case EffectType.ScoreMultiplier:
|
||
summary = "得分倍率x" + amountTotal.ToString("0.###");
|
||
break;
|
||
case EffectType.AddScore:
|
||
summary = "偶像分数" + FormatSignedFloatForLog(amountTotal);
|
||
break;
|
||
case EffectType.IncreaseMaxHP:
|
||
summary = "最大生命值" + FormatSignedIntForLog(Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.DecreaseMaxHP:
|
||
summary = "最大生命值" + FormatSignedIntForLog(-Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.IncreaseMaxMana:
|
||
summary = "最大法力值" + FormatSignedIntForLog(Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.DecreaseMaxMana:
|
||
summary = "最大法力值" + FormatSignedIntForLog(-Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.IncreaseScoreEfficiency:
|
||
summary = "得分效率" + FormatSignedFloatForLog(Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.DecreaseScoreEfficiency:
|
||
summary = "得分效率" + FormatSignedFloatForLog(-Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.IncreaseDamageResistance:
|
||
summary = "伤害抗性" + FormatSignedFloatForLog(Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.DecreaseDamageResistance:
|
||
summary = "伤害抗性" + FormatSignedFloatForLog(-Mathf.Abs(amountTotal));
|
||
break;
|
||
case EffectType.IncreaseAttack:
|
||
summary = "攻击力" + FormatSignedIntForLog(Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.DecreaseAttack:
|
||
summary = "攻击力" + FormatSignedIntForLog(-Mathf.CeilToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.RedirectNextDamageToSelf:
|
||
summary = "下一次承伤转移到自身";
|
||
break;
|
||
case EffectType.RedirectSelfDamageToAdjacent:
|
||
summary = "下一次承伤转移到相邻偶像";
|
||
break;
|
||
case EffectType.GrantExtraPerfect:
|
||
summary = "额外Perfect判定+" + Mathf.Max(1, Mathf.RoundToInt(Mathf.Abs(amountTotal)));
|
||
break;
|
||
case EffectType.RewriteNonMissToPerfect:
|
||
summary = "非Miss判定改写为Perfect";
|
||
break;
|
||
case EffectType.BuffDuration:
|
||
summary = "增益效果倍率x" + amountPerTick.ToString("0.###");
|
||
break;
|
||
case EffectType.DebuffDuration:
|
||
summary = "减益效果倍率x" + amountPerTick.ToString("0.###");
|
||
break;
|
||
default:
|
||
summary = "数值" + FormatSignedFloatForLog(amountTotal);
|
||
break;
|
||
}
|
||
|
||
if (def.defaultDuration > 0f)
|
||
{
|
||
summary += ",持续" + def.defaultDuration.ToString("0.##") + "s";
|
||
if (isOverTime)
|
||
{
|
||
float tick = Mathf.Max(0.01f, def.GetEffectiveTickInterval());
|
||
summary += ",间隔" + tick.ToString("0.##") + "s";
|
||
}
|
||
}
|
||
|
||
return summary;
|
||
}
|
||
|
||
private static bool IsOverTimeEffectForLog(EffectType effectType)
|
||
{
|
||
return effectType == EffectType.DamageOverTimeEnemy
|
||
|| effectType == EffectType.DamageOverTimeAlly
|
||
|| effectType == EffectType.HealOverTimeEnemy
|
||
|| effectType == EffectType.HealOverTimeSelf
|
||
|| effectType == EffectType.HealGroupOverTime
|
||
|| effectType == EffectType.IncreaseManaOverTime;
|
||
}
|
||
|
||
private static string FormatSignedFloatForLog(float value)
|
||
{
|
||
if (value >= 0f) return "+" + value.ToString("0.###");
|
||
return value.ToString("0.###");
|
||
}
|
||
|
||
private static string FormatSignedIntForLog(int value)
|
||
{
|
||
if (value >= 0) return "+" + value.ToString();
|
||
return value.ToString();
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|