备份,准备做双端

This commit is contained in:
2026-07-30 23:15:58 +08:00
parent add675e45d
commit ec4ec53545
88 changed files with 4050 additions and 872 deletions
+115 -43
View File
@@ -69,7 +69,8 @@ public class SkillBuilder : MonoBehaviour
// Reusable buffers to reduce allocations during gameplay
private readonly Dictionary<string, float> _varsBuffer = new Dictionary<string, float>(16);
private readonly List<string> _tmpNoteIdRemoval = new List<string>(16);
private readonly Dictionary<string, float> _lastSkillTriggerTime = new Dictionary<string, float>(256);
// (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);
@@ -196,6 +197,9 @@ public class SkillBuilder : MonoBehaviour
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()
@@ -235,7 +239,8 @@ public class SkillBuilder : MonoBehaviour
}
// Apply as single-instance damage to current enemies; EffectSystem will pick first applicable enemy for DamageSingleEnemy
global::EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null);
LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem");
if (GameConfig.verboseLogs)
LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem");
}
private void ApplySharedOnNoteHit(int trackIndex, string judgeResult)
@@ -436,15 +441,22 @@ public class SkillBuilder : MonoBehaviour
}
// 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)
{
List<GameObject> list = new List<GameObject>();
// If caller provided a specificTarget, honor it as the single target regardless of selector.
if (specificTarget != null)
{
list.Add(specificTarget);
return list;
return new List<GameObject> { specificTarget };
}
List<GameObject> list = _resolveLocalGatherBuffer;
list.Clear();
switch (selector)
{
case Selector.Self:
@@ -461,7 +473,11 @@ public class SkillBuilder : MonoBehaviour
{
if (go != null) list.Add(go);
}
if (source != null) list.RemoveAll(g => g == null || g == source);
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;
@@ -524,9 +540,15 @@ public class SkillBuilder : MonoBehaviour
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;
}
list.RemoveAll(x => x == null);
var uniq = new List<GameObject>();
foreach (var g in list) if (!uniq.Contains(g)) uniq.Add(g);
// 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;
}
@@ -845,10 +867,31 @@ public class SkillBuilder : MonoBehaviour
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;
return s_refreshOnlyTimedSkillIds.Contains(def.skillId);
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)
@@ -961,7 +1004,7 @@ public class SkillBuilder : MonoBehaviour
StopCoroutine(running);
}
if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied))
if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied, key))
{
_refreshOnlyTimedEffectStates.Remove(key);
_refreshOnlyTimedEffectCoroutines.Remove(key);
@@ -1021,7 +1064,7 @@ public class SkillBuilder : MonoBehaviour
_refreshOnlyTimedEffectCoroutines.Remove(key);
}
private bool TryApplyRefreshOnlyTimedEffectNow(GameObject target, EffectType effectType, float amount, out RefreshOnlyTimedState state)
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;
@@ -1058,11 +1101,14 @@ public class SkillBuilder : MonoBehaviour
int atkDelta = Mathf.CeilToInt(Mathf.Abs(amount));
if (effectType == EffectType.DecreaseAttack) atkDelta = -atkDelta;
int oldAtk = ally != null ? ally.attack : enemy.attack;
if (ally != null) ally.ModifyAttack(atkDelta);
else enemy.ModifyAttack(atkDelta);
// 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 };
state = new RefreshOnlyTimedState { effectType = effectType, intDelta = atkDelta, attackModifierKey = atkKey };
return true;
case EffectType.IncreaseMaxHP:
@@ -1148,8 +1194,17 @@ public class SkillBuilder : MonoBehaviour
case EffectType.IncreaseAttack:
case EffectType.DecreaseAttack:
if (ally != null) ally.ModifyAttack(-state.intDelta);
else enemy.ModifyAttack(-state.intDelta);
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:
@@ -1438,10 +1493,15 @@ ResolvedGroup:
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"] = casterAlly.attack;
vars["attack"] = attackPercentOffBase ? casterAlly.GetBaseAttack() : casterAlly.attack;
vars["maxHP"] = casterAlly.maxHP;
vars["maxMana"] = casterAlly.maxMana;
vars["damageResistance"] = casterAlly.damageResistance;
@@ -1547,7 +1607,7 @@ ResolvedGroup:
if (def.repeatWindowSeconds > 0f && def.repeatValue != 0f)
{
string sid = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name;
string repeatKey = $"{slotIndex}:{sid}";
(int, string) repeatKey = (slotIndex, sid);
float now = GameplayClock.NowSongTime;
if (_lastSkillTriggerTime.TryGetValue(repeatKey, out float last) && (now - last) <= def.repeatWindowSeconds)
{
@@ -1556,24 +1616,27 @@ ResolvedGroup:
_lastSkillTriggerTime[repeatKey] = now;
}
try
if (GameplaySkillLogger.Enabled)
{
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);
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)
@@ -3381,7 +3444,10 @@ ResolvedGroup:
}
}
private Dictionary<string, float> _lastOnNoteHitTriggerTime = new Dictionary<string, float>();
// 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>();
@@ -3391,7 +3457,10 @@ ResolvedGroup:
// noteType indicates whether the hit event came from a Tap or Hold (tail)
public bool NotifyNoteHit(int trackIndex, string judgeResult, SkillDefinition.NoteTypeTrigger noteType = SkillDefinition.NoteTypeTrigger.Tap, string uniqueNoteId = null)
{
LogVerbose($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}");
// 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();
@@ -3510,11 +3579,13 @@ ResolvedGroup:
if (quality < required) continue;
}
// cooldown per slot+skill
string key = $"{trackIndex}:{def.skillId}";
// cooldown per (slot, skill) — tuple key, no per-hit string alloc
float now = GameplayClock.NowSongTime;
if (def.onNoteHitCooldown > 0f)
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;
@@ -3522,9 +3593,10 @@ ResolvedGroup:
}
// trigger
LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
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);
_lastOnNoteHitTriggerTime[key] = now;
if (hasCooldown) _lastOnNoteHitTriggerTime[key] = now;
anyTriggered = true;
}
}